1.3 Java中的函数

编程语言中的函数一词通常是指方法,尤其是静态方法,这是在数学函数,也就是没有副作用的函数之外的一个新含义。幸运的是,你将会看到,当Java 8提到函数时,这两种用法几乎是一致的。

Java 8中新增了函数,作为值的一种新形式。它有助于使用1.4节中谈到的流,有了它,Java 8可以在多核处理器上进行并行编程。首先来展示一下作为值的函数本身的有用之处。

想想Java程序可能操作的值吧。首先有原始值,比如42(int类型)和3.14(double类型)。其次,值可以是对象(更严格地说是对象的引用)。获得对象的唯一途径是利用new,这也许是通过工厂方法或库函数实现的;对象引用指向一个类的实例。例子包括"abc"String类型)、new Integer(1111)Integer类型),以及new HashMap(100)的结果——它显式调用了HashMap的构造函数。甚至数组也是对象。那么有什么问题呢?

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

1.3.1 方法和Lambda作为一等值

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

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

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

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

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

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

哇!酷不酷?你已经有了函数isHidden,因此只需用Java 8的方法引用::语法(即“把这个方法作为值”)将其传给listFiles方法。请注意,我们也开始用函数代表方法了。稍后会解释这个机制是如何工作的。一个好处是,你的代码现在读起来更接近问题的陈述了。

方法不再是二等值了。与用对象引用传递对象类似(对象引用是用new创建的),在Java 8里写下File::isHidden的时候,你就创建了一个方法引用,你同样可以传递它。第3章会详细讨论这一概念。只要方法中有代码(方法中的可执行部分),那么用方法引用就可以传递代码,如图1-3所示。图1-4说明了这一概念。你在下一节中还将看到一个具体的例子——从库存中选择苹果。

1.3 Java中的函数 - 图1

图 1-4 将方法引用File::isHidden传递给listFiles方法

Lambda——匿名函数

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

5最初是根据希腊字母λ命名的。虽然Java中不使用这个符号,但是名称还是被保留了下来。

1.3.2 传递代码:一个例子

来看一个例子,看看它是如何帮助你写程序的,我们在第2章还会进行更详细的讨论。所有的示例代码均可见于图灵社区本书主页http://ituring.com.cn/book/2659“随书下载”处。假设你有一个Apple类,它有一个getColor方法,还有一个变量inventory保存着一个Apples列表。你可能想要选出所有的绿苹果(此处使用包含值GREENREDColor枚举类型 ),并返回一个列表。通常用筛选(filter)一词来表达这个概念。在Java 8之前,你可能会写这样一个方法filterGreenApples

  1. public static List<Apple> filterGreenApples(List<Apple> inventory){
  2. List<Apple> result = new ArrayList<>(); ←---- result是用来累积结果的List,开始为空,然后一个个加入绿苹果
  3. for (Apple apple: inventory){
  4. if (GREEN.equals(apple.getColor())) { ←---- 加粗显示的代码会仅仅选出绿苹果
  5. result.add(apple);
  6. }
  7. }
  8. return result;
  9. }

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

  1. public static List<Apple> filterHeavyApples(List<Apple> inventory){
  2. List<Apple> result = new ArrayList<>();
  3. for (Apple apple: inventory){
  4. if (apple.getWeight() > 150) { ←---- 这里加粗显示的代码会仅仅选出重的苹果
  5. result.add(apple);
  6. }
  7. }
  8. return result;
  9. }

我们都知道软件工程中复制粘贴的危险——给一个做了更新和修正,却忘了另一个。嘿,这两个方法只有一行不同:if里面加粗的那行条件。如果这两个加粗的方法之间的差异仅仅是接受的重量范围不同,那么你只要把接受的重量上下限作为参数传递给filter就行了,比如指定(150, 1000)来选出重的苹果(超过150克),或者指定(0, 80)来选出轻的苹果(低于80克)。

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

  1. public static boolean isGreenApple(Apple apple) {
  2. return GREEN.equals(apple.getColor());
  3. }
  4. public static boolean isHeavyApple(Apple apple) {
  5. return apple.getWeight() > 150;
  6. }
  7. public interface Predicate<T>{ ←---- 写出来是为了清晰(平常只要从java.util.function导入就可以了)
  8. boolean test(T t);
  9. }
  10. static List<Apple> filterApples(List<Apple> inventory,
  11. Predicate<Apple> p) { ←---- 方法作为Predicate参数p传递进去(见附注栏“什么是谓词?”)
  12. List<Apple> result = new ArrayList<>();
  13. for (Apple apple: inventory){
  14. if (p.test(apple)) { ←---- 苹果符合p所代表的条件吗
  15. result.add(apple);
  16. }
  17. }
  18. return result;
  19. }

要用它的话,你可以写:

  1. filterApples(inventory, Apple::isGreenApple);

或者

  1. filterApples(inventory, Apple::isHeavyApple);

接下来的两章会详细讨论它是怎么工作的。现在重要的是你可以在Java 8里面传递方法了!

什么是谓词?

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

1.3.3 从传递方法到Lambda

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

  1. filterApples(inventory, (Apple a) -> GREEN.equals(a.getColor()) );

或者

  1. filterApples(inventory, (Apple a) -> a.getWeight() > 150 );

甚至

  1. filterApples(inventory, (Apple a) -> a.getWeight() < 80 ||
  2. RED.equals(a.getColor()) );

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

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

  1. static <T> Collection<T> filter(Collection<T> c, Predicate<T> p);

这样你甚至不需要写filterApples了,因为比如先前的调用

  1. filterApples(inventory, (Apple a) -> a.getWeight() > 150 );

就可以直接调用库方法filter

  1. filter(inventory, (Apple a) -> a.getWeight() > 150 );

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