This website requires JavaScript.

Java 8实战学习笔记(一):为什么要关心Java 8

1.1 Java怎么还在变

Java 8可以透明地把输入的不相关部分拿到几个CPU内核上去分别执行你的Stream操作流水线——这是几乎免费的并行,用不着去费劲搞Thread了。你需要放弃什么吗?你的行为必须能够同时对不同的输入安全地执行。一般情况下这就意味着,你写代码时不能访问共享的可变数据。这些函数有时被称为“纯函数”或“无副作用函数”或“无状态函数”,前面说的并行只有在假定你的代码的多个副本可以独立工作时才能进行。但如果要写入的是一个共享变量或对象,这就行不通了。

Java 8的流实现并行比Java现有的线程API更容易,因此,尽管可以使用synchronized来打破“不能有共享的可变数据”这一规则,但这相当于是在和整个体系作对,因为它使所有围绕这一规则做出的优化都失去意义了。在多个处理器内核之间使用synchronized,其代价往往比你预期的要大得多,因为同步迫使代码按照顺序执行,而这与并行处理的宗旨相悖。

这两个要点(没有共享的可变数据,将方法和函数即代码传递给其他方法的能力)是我们平常所说的函数式编程范式的基石。与此相反,在命令式编程范式中,你写的程序则是一系列改变状态的指令。“不能有共享的可变数据”的要求意味着,一个方法是可以通过它将参数值转换为结果的方式完全描述的;换句话说,它的行为就像一个数学函数,没有可见的副作用。

1.2 Java中的函数

Java 8中新增了函数 -- 值的一种新形式。有了它,Java 8可以进行多核处理器上的并行编程。

我们要注意到,编程语言的整个目的就在于操作值,要是按照历史上编程语言的传统,这些值因此被称为一等值(或一等公民,这个术语是从20世纪60年代美国民权运动中借用来的)。编程语言中的其他结构也许有助于我们表示值的结构,但在程序执行期间不能传递,因而是二等公民。前面所说的值是Java中的一等公民,但其他很多Java概念(如方法和类等)则是二等公民。用方法来定义类很不错,类还可以实例化来产生值,但方法和类本身都不是值。这又有什么关系呢?还真有,人们发现,在运行时传递方法能将方法变成一等公民。这在编程中非常有用,因此Java 8的设计者把这个功能加入到了Java中。顺便说一下,你可能会想,让类等其他二等公民也变成一等公民可能也是个好主意。有很多语言,如Smalltalk和JavaScript,都探索过这条路。

1.2.1 方法和Lambda作为一等公民

Scala和Groovy等语言的实践已经证明,让方法等概念作为一等值可以扩充程序员的工具库,从而让编程变得更容易。一旦程序员熟悉了这个强大的功能,他们就再也不愿意使用没有这一功能的语言了。因此,Java 8的设计者决定允许方法作为值,让编程更轻松。此外,让方法作为值也构成了其他若干Java 8功能(如Stream)的基础。

我们介绍的Java 8的第一个新功能是方法引用。比方说,你想要筛选一个目录中的所有隐藏文件。你需要编写一个方法,然后给它一个File,它就会告诉你文件是不是隐藏的。幸好,File类里面有一个叫作isHidden的方法。我们可以把它看作一个函数,接受一个File,返回一个布尔值。但要用它做筛选,你需要把它包在一个FileFilter对象里,然后传递给File.listFiles方法,如下所示:

File[] hiddenFiles = new File(".").listFiles(new FileFilter() {
    public boolean accept(File file) {
        return file.isHidden();        ←─筛选隐藏文件
    }
});

呃!真可怕!虽然只有三行,但这三行可真够绕的。我们第一次碰到的时候肯定都说过:“非得这样不可吗?”我们已经有一个方法isHidden可以使用,为什么非得把它包在一个啰嗦的FileFilter类里面再实例化呢?因为在Java 8之前你必须这么做!

如今在Java 8里,你可以把代码重写成这个样子:

File[] hiddenFiles = new File(".").listFiles(File::isHidden);

哇!酷不酷?你已经有了函数isHidden,因此只需用Java 8的方法引用::语法(即“把这个方法作为值”)将其传给listFiles方法;请注意,我们也开始用函数代表方法了。一个好处是,你的代码现在读起来更接近问题的陈述了。方法不再是二等值了。与用对象引用传递对象类似(对象引用是用new创建的),在Java 8里写下File::isHidden的时候,你就创建了一个方法引用,你同样可以传递它。只要方法中有代码(方法中的可执行部分),那么用方法引用就可以传递代码。

Lambda——匿名函数

除了允许(命名)函数成为一等值外,Java 8还体现了更广义的将函数作为值的思想,包括Lambda。比如,你现在可以写(int x) -> x + 1,表示“调用时给定参数x,就返回x + 1值的函数”。你可能会想这有什么必要呢?因为你可以在MyMathsUtils类里面定义一个add1方法,然后写MyMathsUtils::add1嘛!确实是可以,但要是你没有方便的方法和类可用,新的Lambda语法更简洁。我们说使用这些概念的程序为函数式编程风格,这句话的意思是“编写把函数作为一等值来传递的程序”。

1.2.2 传递代码:一个例子

假设你有一个Apple类,它有一个getColor方法,还有一个变量inventory保存着一个Apples的列表。你可能想要选出所有的绿苹果,并返回一个列表。通常我们用筛选(filter)一词来表达这个概念。在Java 8之前,你可能会写这样一个方法filterGreenApples:

public static List<Apple> filterGreenApples(List<Apple> inventory){
    List<Apple> result = new ArrayList<>();       ←─result是用来累积结果的List,开始为空,然后一个个加入绿苹果
    for (Apple apple: inventory){
        if ("green".equals(apple.getColor())) { ←─高亮显示的代码会仅仅选出绿苹果
            result.add(apple);
        }
    }
    return result;
}

但是接下来,有人可能想要选出重的苹果,比如超过150克,于是你心情沉重地写了下面这个方法,甚至用了复制粘贴:

public static List<Apple> filterHeavyApples(List<Apple> inventory){
    List<Apple> result = new ArrayList<>();
    for (Apple apple: inventory){
        if (apple.getWeight() > 150) {     ←─这里高亮显示的代码会仅仅选出重的苹果
            result.add(apple);
        }
    }
    return result;
}

嘿,这两个方法只有一行不同:if里面高亮的那行条件。如果这两个高亮的方法之间的差异仅仅是接受的重量范围不同,那么你只要把接受的重量上下限作为参数传递给filter就行了,比如指定(150, 1000)来选出重的苹果(超过150克),或者指定(0, 80)来选出轻的苹果(低于80克)。

但是,我们前面提过了,Java 8会把条件代码作为参数传递进去,这样可以避免filter方法出现重复的代码。现在你可以写:

public static boolean isGreenApple(Apple apple) {
    return "green".equals(apple.getColor());
}
public static boolean isHeavyApple(Apple apple) {
    return apple.getWeight() > 150;
}
public interface Predicate<T>{       ←─写出来是为了清晰(平常只要从java.util.function导入就可以了)
    boolean test(T t);
}
static List<Apple> filterApples(List<Apple> inventory,
                                Predicate<Apple> p) {    ←─方法作为Predicate参数p传递进去
    List<Apple> result = new ArrayList<>();
    for (Apple apple: inventory){
        if (p.test(apple)) {        ←─苹果符合p所代表的条件吗
            result.add(apple);
        }
    }
    return result;
}

要用它的话,你可以写:

filterApples(inventory, Apple::isGreenApple);

或者

filterApples(inventory, Apple::isHeavyApple);

什么是谓词? 前面的代码传递了方法Apple::isGreenApple(它接受参数Apple并返回一个boolean)给filterApples,后者则希望接受一个Predicate<Apple>参数。谓词(predicate)在数学上常常用来代表一个类似函数的东西,它接受一个参数值,并返回truefalse。你在后面会看到,Java 8也会允许你写Function<Apple,Boolean>——在学校学过函数却没学过谓词的读者对此可能更熟悉,但用Predicate<Apple>是更标准的方式,效率也会更高一点儿,这避免了把boolean封装在Boolean里面。

1.2.3 从传递方法到Lambda

把方法作为值来传递显然很有用,但要是为类似于isHeavyApple和isGreenApple这种可能只用一两次的短方法写一堆定义有点儿烦人。不过Java 8也解决了这个问题,它引入了一套新记法(匿名函数或Lambda),让你可以写

filterApples(inventory, (Apple a) -> "green".equals(a.getColor()) );
// 或者
filterApples(inventory, (Apple a) -> a.getWeight() > 150 );
// 甚至
filterApples(inventory, (Apple a) -> a.getWeight() < 80 ||
                                     "brown".equals(a.getColor()) );

所以,你甚至都不需要为只用一次的方法写定义;代码更干净、更清晰,因为你用不着去找自己到底传递了什么代码。但要是Lambda的长度多于几行(它的行为也不是一目了然)的话,那你还是应该用方法引用来指向一个有描述性名称的方法,而不是使用匿名的Lambda。你应该以代码的清晰度为准绳。

Java 8的设计师几乎可以就此打住了,要是没有多核CPU,可能他们真的就到此为止了。我们迄今为止谈到的函数式编程竟然如此强大,在后面你更会体会到这一点。本来,Java加上filter和几个相关的东西作为通用库方法就足以让人满意了,比如

static <T> Collection<T> filter(Collection<T> c, Predicate<T> p);
//这样你甚至都不需要写filterApples了,因为比如先前的调用
filterApples(inventory, (Apple a) -> a.getWeight() > 150 );
// 就可以直接调用库方法filter
filter(inventory, (Apple a) -> a.getWeight() > 150 );

但是,为了更好地利用并行,Java的设计师没有这么做。Java 8中有一整套新的类集合API——Stream,它有一套函数式程序员熟悉的、类似于filter的操作,比如mapreduce,还有我们接下来要讨论的在CollectionsStreams之间做转换的方法。

1.3 流

几乎每个Java应用都会制造处理集合。但集合用起来并不总是那么理想。比方说,你需要从一个列表中筛选金额较高的交易,然后按货币分组。你需要写一大堆套路化的代码来实现这个数据处理命令,如下所示:

Map<Currency, List<Transaction>> transactionsByCurrencies = new HashMap<>();    ←─建立累积交易分组的Map
for (Transaction transaction : transactions) {    ←─遍历交易的List
    if(transaction.getPrice() > 1000){    ←─筛选金额较高的交易
        Currency currency = transaction.getCurrency();    ←─提取交易货币
        List<Transaction> transactionsForCurrency =
            transactionsByCurrencies.get(currency);
        if (transactionsForCurrency == null) {    ←─如果这个货币的分组Map是空的,那就建立一个
            transactionsForCurrency = new ArrayList<>();
            transactionsByCurrencies.put(currency,
                                         transactionsForCurrency);
        }
        transactionsForCurrency.add(transaction);    ←─将当前遍历的交易添加到具有同一货币的交易List中
    }
}

此外,我们很难一眼看出来这些代码是做什么的,因为有好几个嵌套的控制流指令。 有了Stream API,你现在可以这样解决这个问题了:

import static java.util.stream.Collectors.toList;
Map<Currency, List<Transaction>> transactionsByCurrencies =
    transactions.stream()
                .filter((Transaction t) -> t.getPrice() > 1000)    ←─筛选金额较高的交易
                .collect(groupingBy(Transaction::getCurrency));    ←─按货币分组

和Collection API相比,Stream API处理数据的方式非常不同。用集合的话,你得自己去做迭代的过程。你得用for-each循环一个个去迭代元素,然后再处理元素。我们把这种数据迭代的方法称为外部迭代。相反,有了Stream API,你根本用不着操心循环的事情。数据处理完全是在库内部进行的。我们把这种思想叫作内部迭代

Java 8基于Stream的并行提倡很少使用synchronized的函数式编程风格,它关注数据分块而不是协调访问。

Collection主要是为了存储和访问数据,而Stream则主要用于描述对数据的计算。这里的关键点在于,Stream允许并提倡并行处理一个Stream中的元素。虽然可能乍看上去有点儿怪,但筛选一个Collection(将上一节的filterApples应用在一个List上)的最快方法常常是将其转换为Stream,进行并行处理,然后再转换回List,下面举的串行和并行的例子都是如此。我们这里还只是说“几乎免费的并行”,让你稍微体验一下,如何利用Stream和Lambda表达式顺序或并行地从一个列表里筛选比较重的苹果。

顺序处理

import static java.util.stream.Collectors.toList;
List<Apple> heavyApples =
    inventory.stream().filter((Apple a) -> a.getWeight() > 150)
                      .collect(toList());

并行处理

import static java.util.stream.Collectors.toList;
List<Apple> heavyApples =
    inventory.parallelStream().filter((Apple a) -> a.getWeight() > 150)
                              .collect(toList());

1.4 默认方法

0条评论
avatar