最近开始看<<写给大忙人看到Java SE 8>>,由于和我看过的《快学Scala》是一个系列的,所以姑且叫它《快学Java8》。这一篇是第一章的看书笔记,以后每个章节也都会有一篇笔记。
为什么要引入Lambda表达式
Java引入的最重要的特性是lambda表达式。lambda表达式实际上是一段可以传递的代码。在Java8之前,由于Java的设计问题,要传递代码必须构造一个类的对象,它的某个方法包含了需要的代码。看个例子:
1 | class LengthComparator implements Comparator<String>{ |
而其他一些语言可以直接传递代码块。一开始Java的设计者不想要加入这一特性,但随着函数式编程越来越流行,终于在Java8中,我们可以使用lambda来传递代码了。
lambda的语法
lambda由参数,箭头(->),表达式组成。我们用lambda重写上面的代码:
1 | String[] strings = {"a", "bcd", "ef"}; |
如果代码不止不止一行,那么可以使用花括号把多行代码包裹起来:
1 | Arrays.sort(strings,(String o1,String o2) ->{ |
如果lambda表达式没有参数,可以提供一对空的括号:
1 | new Thread(() -> { |
如果labmda表达式的参数类型是可以被推导出来的,可以省略它们的类型,实际上,大多数情况就是这样的。
1 | Comparator<String> comp=(first,second)->Integer.compare(first.length(),second.length()); |
这里就可以推导出first
,second
的类型是字符串,因为comp
被声明为一个字符串比较器。
如果某个lambda表达式只有一个参数,并且它的参数类型是可以被推导出来的,那么我们可以省略括号。
1 | EventHandler<ActionEvent> listener=event -> System.out.println("Click"); |
不需要为lambda表达式声明返回值类型,它会从上下文被推导出。
函数式接口
Java中很多已有的接口都是用来封装代码块的,例如Runnable
,Comparator
。lambda表达式与这些接口是兼容的。
那些只包含一个抽象方法的接口,我们可以使用lambda来创建该接口的对象,这种接口被称为函数式接口。
你可能会奇怪为什么只能有一个抽象方法,难道接口的方法不都是抽象的吗?事实上,接口经常会重新声明Object类中的方法,为了关联Javadoc的注释,Comparator
就是例子。而且,Java8的接口也可以声明非抽象方法。
实际上,lambda唯一做的事情就是函数式接口的转换。在其他语言中,函数可能就是一致类型,但在Java中,lambda表达式实际还是接口的对象。
在java.util.funtion
包中,定义了很多非常通用的函数式接口(我们会在之后两章提到)。其中,有一个BiFuntion<T,U,R>
,描述了参数类型是T和U并且返回值类型是R的函数。我们可以把上面的字符串比较的lambda表达式保存在这个类型的变量中:
1 | BiFunction<String ,String ,Integer> comp=(first,second)->Integer.compare(first.length(),second.length()); |
但这个对象对排序没有什么用,因为不存在接收BiFuntion
作为参数的Arrays.sort
方法。这和其他函数式语言是很不同的。
记住,任意一个lambda表达式都可以等价转换成现在所使用的API中对应的函数式接口。
你可以在任何函数式接口上标注@FunctionalInterface
注解。这样做有两个好处:
- 编译器会检查标注该注解的实体,检查它是否是只包含一个抽象方法的接口。
- 在Javadoc页面会包含一条声明,说明这个接口是函数式接口。
这个注解不是强制要求的,但是为了规范代码,对于函数式接口,都要加上这个注解。
最后,当一个lambda表达式被转换成一个函数式接口实例的时候,要注意处理检查时异常。如果lambda表达式可能抛出检查时异常,那么该异常需要在目标接口的抽象方法中进行声明。一下代码会产生一个错误:
1 | Runnable sleep=() -> { |
Runnable.run
方法是不能抛出异常的,所以这个赋值是不合法的。解决的方法有两个:
- 在lambda表达式中捕获异常
- 把lambda表达式赋值给一个其抽象方法可以抛出异常的接口。
方法引用
有时候,我们想要传递的代码已经有实现的方法了。例如,你只想打印数组中的元素,按照之前的做法,代码可以这么写:
1 | Arrays.stream(new int[]{1,2,3}).forEach(i-> System.out.println(i)); |
但其实我们可以直接把方法传递给forEach
:
1 | Arrays.stream(new int[]{1,2,3}).forEach(System.out::println); |
其中,System.out::println
是一个方法引用,等同于x->System.out.println(x)
。在举一个例子,假如你想不区分大小写地对字符串进行排序,那么可以这样写:
1 | Arrays.sort(new String[]{"a","bcd","ef"},String::compareToIgnoreCase); |
正如实例代码所示,::
操作符把实例或者类的名字和方法名分隔开。下面是三种主要的用法:
- 对象::实例方法
- 类::静态方法
- 类::实例方法
前两种情况,方法引用等同于接收参数并调用方法的lambda表达式,System.out::println
等同于x->System.out.println(x)
,Math::pow
等同于(x,y)->Math.pow(x,y)
。
第三种情况,传入的第一个参数会成为执行方法的对象,例如String::compareToIgnoreCase
,等价于(x,y)->x.compareToIgnoreCase(y)
。
如果有多个同名的重载方法,编译器会试图从上下文中找到最匹配的方法,主要是通过方法参数确定。
和lambda表达式一样,方法引用也不会独立存在,它们经常被用于转换成函数式接口的实例。
你可以在方法引用中使用this和super变量。this::equals
等价于x->this.equals(x)
。使用super可以引用父类的方法:
1 | class Greeter{ |
注意,在一个内部类中,你可以捕获闭合类型的this
或super
的引用,格式是EnclosingClass.this::method
或EnclosingClass.super::method
。看一个例子:
1 | class Greeter{ |
构造器引用
构造器引用和方法应用是一样的,只是方法名固定是new。如果有多个构造器,那么选哪个呢?和方法引用遇到的问题的答案一样,这取决于上下文。
1 | List<String> labels = new ArrayList<>(); |
存在着多个Button构造器,但是编译器会选择有一String参数的构造器,因为它从上下文推测出调用构造器使用一个String。
你可以使用数组类型来建立构造器引用。比如,int[]::new
是带一个参数的构造器引用,这个参数就是数组长度。它等价于x->new int[x]
。
使用数组构造器引用可以很好的克服一个Java的限制。我们知道Java有类型擦除机制,所以在设计库的时候,new T[]
这样是错误的,类型擦除会把它变成new Object[]
。所以,Stream
的一个方法toArray
,它返回的类型是Object
数组。
1 | Object[] btns = buttons.stream().toArray(); |
但这样并不尽如人意,用户肯定是想要一个Button
数组。通过数组构造器引用,Stream库解决了这个问题。你可以把Button[]::new
传入toArray
方法:
1 | Button[] btns2= buttons.stream().toArray(Button[]::new); |
变量作用域
我们常常想要lambda表达式可以访问闭合方法或类中的变量。看下面的例子:
1 | public void repeatMessage(String msg,int count){ |
看一下变量msg
和count
,它们都不是定义在lambda表达式中,而是方法repeatMessage
的参数。
如果你仔细想想,这个lambda可能会运行很长时间,方法repeatMessage
的调用已经返回,方法的参数也没了,那么msg
和 count
是怎么一直存在的呢?要理解发生了什么,需要先改变我们对lambda表达式的理解。一个lambda表达式有三个元素:
- 一个代码块
- 参数
- 自由变量的值。自由变量是指那些既不是lambda表达式的参数也不是在lambda代码块中的定义的变量
上面代码中的msg
和count
就是自由变量。可以说,这两个值已经被lambda捕获了。这是一个技术上的细节,如果你将一个lambda转换成一个只含有一个方法的对象,那么自由变量的值就会被复制到该对象的实例变量中。
我们称含有自由变量的代码块为闭包。实际上,内部类一直都是闭包。Java8为闭包赋予了更吸引人的语法。
为了确保被捕获的值是良好定义的,需要遵守一个重要的约束:在lambda表达式中,被引用的变量的值不可以被更改。所以,下面的代码是不合法的:
做出这种约束的原因是,更改lambda表达式中的变量不是线程安全的。
内部类也会捕获闭合作用域中的值。Java8之前,内部类只允许访问final的局部变量,为了适应lambda表达式,这条规则在Java8被放宽,一个内部类可以访问任何有效的final局部变量,即任何不会发生变化的值,并不一定要以final修饰。
但不可变的约束只作用在局部变量上,如果是一个实例变量或某个闭合类的静态变量,那么不会有任何的错误提示。同样的,改变一个共享对象也是完全合法的,即使这样不是很恰当:
1 | List<Integer> nums = new ArrayList<>(); |
nums
是有效final的,因为它在初始化后没有被赋予新的值。但是,它内部的元素是可以修改的,所以仍然不是线程安全的。后面会介绍一些线程安全的集合。
既然lambda中引用的外部变量是不能更改的,那么怎么实现一个计数器呢?这个问题在使用内部类的时候就已经存在,方法是使用一个的长度为1的数组:
1 | int counter[]={0}; |
lambda表达式中不允许声明一个与局部变量同名的参数或者局部变量:
当你在lambda表达式中使用this关键字时,你使用的是创建该lambda的方法的this参数。
默认方法
很多语言都将函数表达式应用到了集合库中。Java8同样如此。但是,Java的集合库是很早就有的,如果要给Collection
接口添加新的方法,如forEach
,那么所有实现了Collection
接口的类都必须实现这个方法,这令人无法接受也没有必要。Java的设计者通过允许接口带有具体实现的方法来一劳永逸的解决这个问题,这些方法就叫做默认方法。下面是一个例子:
1 | interface Person{ |
默认方法终结了以前的一种经典默认:模板方法模式。 即提供一个接口,以及一个实现接口大多数或者全部方法的抽象类,然后具体实现的类继承这个抽象类,只需实现少量方法就可以。现在,你不需要额外的抽象类了,只需要在接口中实现那些方法。
如果一个接口定义了一个默认方法,而另一个父类或接口中定义了一个同名的方法,该如何选择。规则是这样的:
- 如果一个父类提供了具体的实现方法,那么接口中具有相同名称和参数的默认方法会被忽略。
- 接口冲突。如果一个父接口提供了一个默认方法,而另一个父接口提供了一个具有相同名称和参数类型的方法(不论是不是默认方法),那么你必须通过覆盖该方法类解决冲突。
我们来详细解释一下第二条规则。我们再定义一个接口,它也有getName
方法:
1 | interface Named{ |
如果编写一个同时实现这Person
和Named
两个接口的类,编译器会报告一个错误,并交由开发人员来解决这种冲突,而不会自动选择一个。对于这种情况,只需要在这个类中提供一个getName
方法,在改方法中再选择调用一个接口中的方法就可以了:
1 | class Student implements Person,Named{ |
如果Named
接口没有提供getName
方法的一个默认实现,那么Student
会继承Person
接口中的默认方法吗?答案是,为了保持一致性,还是选择了和之前一样的处理方法。两个或多个接口中,只要有一个接口中有默认方法,其他类中不管是有默认方法还是抽象方法,都需要开发人员手动解决冲突。
如果Person
是一个类,那么Student
只会继承Person
的getName
方法,不管Named
接口中的getNamed
方法是不是默认方法。这就是类优先原则。这个原则可以保证与Java7兼容。如果你在接口中添加了一个默认方法,那么他对Java8以前编写的代码不会有任何影响。所以,你不能为Object的方法重新定义一个默认方法,因为类优先原则会让这样的方法永远不可能优先与Object中的方法。
接口中的静态方法
Java8中,接口是可以添加静态方法的。技术上,这是完全没问题的,但这看起来违反了接口作为一个抽象定义的原则。
至今,我们经常在相互一起使用的类中使用静态方法,如标准库中的Collection/Collections或者Path/Paths这样成对的接口和类。以Paths
为例子,它有一些工程方法,用来产生Path
。在Java8中,你可以把这些方法移到Path
接口中,这样Paths
就没必要存在了。
再看Collections
,你会看到下面的这个方法:
1 | public static void shuffle(List<?> list) |
这个方法可以作为List
接口的默认方法:
1 | public defualt void shuffle() |
然后只需要在任意的list对象上调用list.shuffle()
就可以了。
但是,这样无法适用于静态的工厂方法,因为没有实例来调用这个方法。因此,Java8引入了静态接口方法。例如,可以把Collections
中的nCopies
方法移到List
接口:
1 | public static <T> List<T> nCopies(int n,T o) |
这样就可以调用List.nCopies(10,"jjzi")
而不是Collections.nCopies(10,"jjzi")
,这样能够清楚的表示返回的结果是一个List
。
虽然为了兼容性,不太可能这么大幅度的重构,但是当你实现自己的库的时候,可以考虑使用这种方式,不必在添加一个额外的辅助类存放静态方法。
Java8中,很多接口已经添加了静态方法。例如,Comparator
接口提供了一个很实用的比较方法,它接受一个键提取函数,并返回一个用来比较提取出的键的比较器。比如,要根据名称对Person
对象进行比较,可以使用函数Comparator.comparing(Person::getName)
。
在之前,我们曾经使用lambda表达式(first,second)->Integer.compare(first.length(),second.length())
,其实可以使用Comparator
的静态比较方法,代码会更简洁。例如Comparator.comparingInt(String::length)
。