原文地址:Modern Java - A Guide to Java 8
作者: winterbe
这篇文章原本在我的博客发布。
你也该看看我的 Java 11 指南(包括 Java 9, 10, 11 的新语法特性以及新 API 的介绍)。
欢迎阅读。这篇指南会一步步地引导你过一遍所有的 Java 8 新特性。通过简短的示例代码,你会学习到如何使用接口的默认方法、lambda 表达式、方法引用以及可重复注解(annotation)。文章结束时你将会熟悉大部分 API 的变化,如 streams、函数式接口、map 接口的扩展以及新的日期 API。本文主要通过代码及其备注讲解,而不是一大堆说明文字,你可以尽情享用。
- 接口的默认方法
- Lambda 表达式
- 函数式接口
- 方法与构造函数的引用
- Lambda 表达式变量的作用域
- 内置的函数式接口
- Optionals(可选对象)
- Streams
- Parallel Streams(并行 stream)
- Map 接口的扩展
- 新的日期 API
- annotation 注解的扩展
- 后续阅读
接口的默认方法
Java 8 允许使用者通过 default
关键字来为接口添加带有具体实现的非抽象方法。这个特性也被称为虚拟延伸方法(virtual extension methods)。
首先来看示例代码:
1 | interface Formula { |
接口 Formula
除了抽象方法 caculate
之外,还定义了一个 default 方法 sqrt
。该接口的实现类只需要实现抽象方法 calculate
即可。而 default 方法 sqrt
是开箱即用的。
1 | Formula formula = new Formula() { |
以上示例中,Formula
以匿名对象(匿名类的对象)的方式被实例化。这段冗长的代码花费了 6 行代码去实现一个简单的 sqrt(a * 100)
计算。我们将在下一部分探讨在 Java 8 中如何以简洁得多的方式去实现只有一个抽象方法的接口。
Lambda 表达式
先来看看在旧版 Java 中如何对一个字符串列表进行排序:
1 | List<String> names = Arrays.asList("peter", "anna", "mike", "xenia"); |
静态工具方法 Collections.sort
先后接收一个列表与一个比较器,从而对给定列表的元素进行排序。在实际开发中,我们经常会创建匿名比较器并将之传递给 sort
方法。
在 Java 8 中,我们将无需如此频繁地创建匿名对象,即可以使用更简洁的语法:lambda 表达式:
1 | Collections.sort(names, (String a, String b) -> { |
如你所见,这段只有 3 行的代码比原来的代码更短、更便于阅读,但它仍然可以进一步简化:
1 | Collections.sort(names, (String a, String b) -> b.compareTo(a)); |
通过省略代表方法体的花括号 {}
以及 return
关键字,代码缩短为只有一行,但它仍有简化的空间:
1 | names.sort((a, b) -> b.compareTo(a)); |
List
接口新增的 default 方法 sort
接收一个比较器。Java 编译器能自行推断该比较器的参数类型,因此你可以将之省略。
下面让我们更深入地了解在实际开发中如何使用 lambda 表达式。
函数式接口
Lambda 表达式如何与 Java 的类型系统匹配?答案是每个 Lambda 表达式都与一个给定的类型相关,该类型由接口定义。一个所谓的函数式接口必须声明唯一的抽象方法。每个类型的 lambda 表达式都与该抽象方法相匹配。由于 default 方法并非抽象方法,因此在函数式接口中可以随意定义。
只要接口中仅含一个抽象方法,我们就能把这种接口写成 lambda 表达式。为了确保接口满足函数式接口的要求,你可以为其添加 @FunctionalInterface
注解。编译器会识别该注解并在你声明第二个抽象方法时抛出异常。
示例:
1 |
|
1 | Converter<String, Integer> converter = (from) -> Integer.valueOf(from); |
注意即使忽略 @FunctionalInterface
注解,这段代码仍然是有效的。
方法与构造函数的引用
以上的示例代码可以用静态方法引用来进一步简化:
1 | Converter<String, Integer> converter = Integer::valueOf; |
Java 8 允许你通过 ::
关键字来传递方法或构造器的引用。以上示例演示了如何引用一个静态方法。同样我们也可以引用实例的方法:
1 | class Something { |
1 | Something something = new Something(); |
让我们来看看 ::
关键字如何用于构造器。首先我们定义一个有不同构造器的类:
1 | class Person { |
接下来我们定义一个用于创建对象的工厂接口:
1 | interface PersonFactory<P extends Person> { |
与手动实现工厂接口的方式不同,我们可以通过构造器引用将以上代码整合起来:
1 | PersonFactory<Person> personFactory = Person::new; |
我们通过代码 Person::new
创建了一个对 Person
构造器的引用。Java 编译器会自动根据 PersonFactory.create
的方法签名来匹配相应的构造器。
Lambda 表达式变量的作用域
在 lambda 表达式中访问外部变量的方式与匿名对象访问外部变量的方式非常相似。你可以访问 final
声明的方法本地变量、对象的属性及类的静态变量。
访问方法的本地变量
我们能在 lambda 表达式内部对外部的 final 变量进行访问:
1 | final int num = 1; |
但与匿名对象不同的是,变量 num
不一定要声明为 final。以下示例同样是有效的:
译者注:此处有误。隐式 final 声明的本地变量,无论是引用类型还是基本类型,匿名对象与 lambda 表达式均能对其进行访问。
1 | int num = 1; |
但是变量 num
必须能在编译时等价于隐式 final。以下代码不能通过编译:
1 | int num = 1; |
在 lambda 表达式内部修改 num
的值也是不被允许的。
访问属性及静态变量
不同于对方法的本地变量只能读取,我们可以在 lambda 表达式内对实例的属性及类的静态变量进行读写。该行为与匿名对象的行为一致。
1 | class Lambda4 { |
访问接口的默认方法
还记得第一部分的 formula 示例代码吗?Formula
接口定义了一个 default 方法 sqrt
,这个方法可以在所有 formula 的实例,包括匿名对象中进行访问。但这不包括 lamda 表达式。
不能在 lambda 表达式中访问 default 方法,以下代码将不能通过编译:
1 | Formula formula = (a) -> sqrt(a * 100); |
译者注:default 方法是实例方法,因此要调用的前提是相应的匿名对象已被创建。通过测试代码观察 lambda 表达式与传统匿名对象的实例化方式的区别:
1 |
|
内置的函数式接口
JDK 1.8 API 提供了许多内置的函数式接口。有些是在旧版 Java 中就已经很有名,如 Comparator
和 Runnable
。这些接口通过 @FunctionalInterface
注解扩展为函数式接口以支持 lambda 表达式。
但在 Java 8 API 中同样有许多新的函数式接口来简化你的代码。这些新接口中有些是来自有名的 Google Guava 库。即使你熟悉该库,你也应该留意这些接口中添加了哪些有用的扩展。
Predicates(论断)
java.util.function.Predicate
是只有一个参数、基于 boolean 类型的函数。该接口提供不同的 default 方法用以将多个 predicate(论断)组合成复杂的逻辑判断条件(与、或、非)。
1 | Predicate<String> predicate = (s) -> s.length() > 0; |
Functions(函数)
java.util.function.Function
接收一个参数并返回一个结果。该接口的 default 方法允许使用者将多个 Function
链接起来(compose
, andThen
)。
1 | Function<String, Integer> toInteger = Integer::valueOf; |
Suppliers(供应者)
java.util.function.Supplier
产生一个特定类型的结果。与 Function
不同的是,Supplier
不接收任何参数。
1 | Supplier<Person> personSupplier = Person::new; |
Consumers(消费者)
java.util.function.Consumer
代表对单一参数的一系列操作。
1 | Consumer<Person> greeter = (p) -> System.out.println("Hello, " + p.firstName); |
Comparators(比较器)
java.util.Comparator
比较器来自旧版 Java,在当前版本添加了大量 default 方法。
1 | Comparator<Person> comparator = (p1, p2) -> p1.firstName.compareTo(p2.firstName); |
Optionals(可选对象)
java.util.Optional
并不是函数式接口,而是用于巧妙地避免 NullPointerException 的工具。它是下一部分的重要概念,因此我们先来简略地看看它是如何工作的。
Optional
是一个包含一个对象的简单容器,该对象可以是空或非空。在旧版 Java 中,当调用一个有返回值的方法时,该返回值有可能为空或非空。在 Java 8 中可以通过返回一个 Optional
对象来避免方法的返回值为空对象的情况。
1 | Optional<String> optional = Optional.of("bam"); |
Streams
一个 java.util.stream.Stream
的实例就是一系列元素的合集,你可以对这些元素进行多项操作。
对 stream 的操作有中间操作和终点操作两种。中间操作返回的是 stream 对象,因此开发者可以用一行代码将多个中间操作串联起来,而终点操作返回的是特定类型的处理结果。Stream 对象的创建需要数据源,如 java.util.Collection
的实例 List 和 Set(但 Map 不支持)。
Stream 的操作既可是串行,也可以是并行。
Stream 极其强大,因此我单独写了一篇 Java 8 Stream 指南。Sequency 库提供了相似功能,你可以将之用于 web 开发。
首先看看 stream 是如何工作的。创建以字符串列表的形式创建数据源:
1 | List<String> stringCollection = new ArrayList<>(); |
Collection 集合框架在 Java 8 中被扩展,因此你可以使用 Collection.stream()
或 Collection.parallelStream()
的方式创建 stream 对象。接下来的部分会讲解最常用的 stream 操作。
Filter
Filter 接收一个 predicate 用以对 stream 中的所有元素进行过滤。这是个中间操作,因此我们可以继续调用其他 stream 操作(如示例中的 forEach
)。ForEach 接收一个 consumer 并对 filter 返回的 stream 中的各个元素进行操作。ForEach 是一个终点操作,返回值为 void
,因此我们无法调用其他操作。
1 | stringCollection |
Sorted
Sorted 是一个中间操作,返回 stream 排序后的视图。Stream 中的元素默认按其自然顺序进行排序,也可传入自定义的比较器进行排序。
1 | stringCollection |
谨记 sorted
仅仅创建了 stream 元素的视图,并没有修改数据源的排序。stringCollection
的排序是没有被修改过的:
1 | System.out.println(stringCollection); |
Map
map
是中间操作,根据给定的 function 将各个元素转化为其他对象。以下示例将每个字符串对象转换为相应的大写形式。当然你也可以用 map
将各个对象转换至其他类型。map 操作返回的 stream 的泛型将由其接收的 function 的泛型决定。
1 | stringCollection |
Match
match 有多种操作,用以验证 stream 是否与给定 predicate 相符。所有的 match 操作都是终点操作并返回一个布尔值。
1 | boolean anyStartsWithA = |
Count
Count 是一个终点操作,以 long
的方式返回最终 stream 中的元素数量。
1 | long startsWithB = |
Reduce
reduce
为终点操作,接收一个 function 并一次对元素进行减量处理,结果保存在返回的 Optional
中。
1 | Optional<String> reduced = |
译者注:reduce()
接收的 function 并不是上文提及的 java.util.function.Function
,而是 java.util.function.BinaryOperator<T>
,继承自同一包下的 BiFunction<T,U,R>
。后者为 Function<T, R>
的双参数版本,即接收参数 T 和 U 并返回类型 R。前者则是对后者的简化,即 T,U,R 的类型相同均为 T,但本质上仍然是接收两个参数并返回一个结果的函数。而 reduce
的工作过程为按顺序依次访问两个元素,将这两个元素作为 function 的参数并返回一个结果,该结果作为新的元素与下一个原 stream 元素继续作为 function 的两个参数,如此类推,最终返回一个 Optional
对象。
Parallel Streams(并行 stream)
如上文所述,stream 可以按串行(单线程)或并行(多线程)的方式进行处理。
以下示例展示了通过使用并行 stream 来提升性能。
我们先创建一个包含大量唯一元素的列表:
1 | int max = 1000000; |
接下来测试该 stream 排序的耗时。
Sequential Sort
串行排序
1 | long t0 = System.nanoTime(); |
Parallel Sort
并行排序
1 | long t0 = System.nanoTime(); |
如你所见,上面两部分代码几乎完全相同,但并行排序的速度提高了将近 50%,而你需要做的只不过是把 stream()
改为 parallelStream()
。
Map 接口的扩展
正如前文所提及,Map
接口并不直接支持 stream 的功能,该接口没有直接定义 stream()
方法,但你可以单独对 key,value 或 entry 创建 stream,相应的方法为 map.keySet().stream()
,map.values().stream()
和 map.entrySet().stream()
。
另外,Map
接口也提供了各种有用的新方法来完成一些普遍性的工作。
1 | Map<Integer, String> map = new HashMap<>(); |
以上代码应该能够解释自己:putIfAbsent
让我们无需编写额外的判空逻辑。forEach
接收一个 consumer 以便对 map 中的每个 entry 进行操作。
下列示例展示了如何使用 function 来对 map 的值进行计算:(注意 present 与 absent)
1 | map.computeIfPresent(3, (num, val) -> val + num); |
译者注:Map
接口的 forEach
方法接收的 consumer 与 computeIfPresent
方法接收的 function 均是各自的双参数版本,分别为 BiConsumer<K, V>
和 BiFunction<T>
。
接下来看看如何在只有 value 也满足给定值的情况下移除特定 key。(即 key-value 均满足给定值的前提下移除词条)
1 | map.remove(3, "val3"); |
另一个有用的方法:
1 | map.getOrDefault(42, "not found"); // not found |
轻松合并 map 中的词条:
1 | map.merge(9, "val9", (value, newValue) -> value.concat(newValue)); |
当 key 不存在时,merge
会把 value(第二个参数)保存到 map 中;当 key 已存在时,将此时的值(原值)与传入的值(方法的第二个参数)作为 function 的两个参数,转换成新的值并保存到 key 中。
新的日期 API
Java 8 增加了全新日期与时间 API,位于 java.time
包下。新的日期 API 可与 Joda-Time 库进行比较,但它们并不相同。下面的示例会涵盖新 API 中最重要的部分。
Clock
Clock 提供了对当前日期与时间的访问方式。Clock 基于时区,可作为 System.currentTimeMillis()
的替代方式,获取从 Unix EPOCH(1970年1月1日0时0分0秒) 至今的毫秒数。如此一个在时间轴上瞬间的点也可用 Instant
类来表示,它可用于创建旧版日期 java.util.Date
对象。
1 | Clock clock = Clock.systemDefaultZone(); |
Timezones
时区由 ZoneId
表示,它可以通过静态工厂方法轻松获取。时区定义了不同地区之间的当地日期与时间的偏移值。
1 | System.out.println(ZoneId.getAvailableZoneIds()); |
LocalTime
LocalTime 就是不带时区的时间,如 10pm 或 17:30:15。以下示例根据之前定义的时区创建了两个 LocalTime
对象,然后比较这两个时间的先后以及计算他们在小时与分钟上的差值。
1 | LocalTime now1 = LocalTime.now(zone1); |
LocalTime 对象的创建可以由不同的工厂方法来简化,包括解析字符串的方式。
1 | LocalTime late = LocalTime.of(23, 59, 59); |
LocalDate
LocalDate 代表一个具体的日期,如 2014-03-11。LocalDate 为不可变对象,并且可以模拟 LocalTime 的工作方式。以下示例展示了如何通过增减天数、月数或年数,从而计算出新的日期。谨记每次操作都会返回一个新的 LocalDate 实例。
1 | LocalDate today = LocalDate.now(); |
通过解析字符串的方式创建 LocalDate,就如解析 LocalTime 的方式一样简单:
1 | DateTimeFormatter germanFormatter = |
LocalDateTime
LocalDateTime 代表了一个日期与时间,它将上述的日期与时间两部分组合成一个对象。LocalDateTime
是不可变对象且工作方式与 LocalTime 和 LocalDate 相似。我们可以利用不同的方法从一个日期时间对象中获取特定的成员。
1 | LocalDateTime sylvester = LocalDateTime.of(2014, Month.DECEMBER, 31, 23, 59, 59); |
通过时区这个额外的信息,LocalDateTime 可以转换成一个 Instant
对象,后者可以轻松地转换成旧版日期类型 java.util.Date
。
1 | Instant instant = sylvester |
格式化日期时间(date-time)的方式与日期或时间的格式化相似。与使用预定义的格式不同,我们可以通过自定义的方式来格式化 date-time。
1 | DateTimeFormatter formatter = |
与 java.text.NumberFormat
不同,新的 DateTimeFormatter
是不可变、线程安全类。
查看详细的格式化模板语法可点击此处。
annotation 注解的扩展
Java 8 的 annotation 允许重复声明。让我们通过一个示例来深入理解这一点。
首先,我们定义一个容器注解,该注解持有一个真实注解的数组:
1 | Hints { |
Java 8 允许我们通过 @Repeatable
注解来声明多个同类型注解。
变体 1:通过一个容器注解来声明多个同类型注解(过时做法)
1 |
|
变体 2:使用可重复注解来声明多个同类型注解(最新做法)
1 |
|
使用变体 2 的方式时,Java 编译器会隐式地设置 @Hints
。这对于在反射中读取 annotation 注解时非常重要。
1 | Hint hint = Person.class.getAnnotation(Hint.class); |
尽管我们从未对 Person
类声明 @Hints
注解,但该注解仍可通过 getAnnotation(Hints.class)
的方式读取。但是更便捷的方式是调用 getAnnotationsByType
方法,该方法允许直接访问所有声明的 Hint
注解。
Java 8 中其他关于注解的扩展内容是两个新的 @Target
类型:
1 |
|
后续阅读
- Java 8 Stream Tutorial
- Java 8 Nashorn Tutorial
- Java 8 Concurrency Tutorial: Threads and Executors
- Java 8 Concurrency Tutorial: Synchronization and Locks
- Java 8 Concurrency Tutorial: Atomic Variables and ConcurrentMap
- Java 8 API by Example: Strings, Numbers, Math and Files
- Avoid Null Checks in Java 8
- Fixing Java 8 Stream Gotchas with IntelliJ IDEA
- Using Backbone.js with Java 8 Nashorn
你可以在 Twitter 上关注我。感谢阅读!