26.5 Java 8的日期和时间API

本节介绍Java 8对日期和时间API的增强。我们在之前介绍了Java 8以前的日期和时间API,主要的类是Date和Calendar,由于它的设计有一些不足,Java 8引入了一套新的API,位于包java.time下。本节我们就来简要介绍这套新的API,先从日期和时间的表示开始。

26.5.1 表示日期和时间

我们在第7章介绍过日期和时间的几个基本概念,包括时刻、时区和年历,这里就不赘述了。Java 8中表示日期和时间的类有多个,主要的有:

  • Instant:表示时刻,不直接对应年月日信息,需要通过时区转换;
  • LocalDateTime:表示与时区无关的日期和时间,不直接对应时刻,需要通过时区转换;
  • ZoneId/ZoneOffset:表示时区;
  • LocalDate:表示与时区无关的日期,与LocalDateTime相比,只有日期,没有时间信息;
  • LocalTime:表示与时区无关的时间,与LocalDateTime相比,只有时间,没有日期信息;
  • ZonedDateTime:表示特定时区的日期和时间。

类比较多,但概念更为清晰了,下面我们逐个介绍。

1. Instant

Instant表示时刻,获取当前时刻,代码为:

1
Instant now = Instant.now();

可以根据Epoch Time(纪元时)创建Instant。比如,另一种获取当前时刻的代码可以为:

1
Instant now = Instant.ofEpochMilli(System.currentTimeMillis());

我们知道,Date也表示时刻,Instant和Date可以通过纪元时相互转换,比如,转换Date为Instant,代码为:

1
2
3
public static Instant toInstant(Date date) {
return Instant.ofEpochMilli(date.getTime());
}

转换Instant为Date,代码为:

1
2
3
public static Date toDate(Instant instant) {
return new Date(instant.toEpochMilli());
}

Instant有很多基于时刻的比较和计算方法,大多比较直观,我们就不列举了。

2. LocalDateTime

LocalDateTime表示与时区无关的日期和时间,获取系统默认时区的当前日期和时间,代码为:

1
LocalDateTime ldt = LocalDateTime.now();

还可以直接用年月日等信息构建LocalDateTime。比如,表示2017年7月11日20点45分5秒,代码可以为:

1
LocalDateTime ldt = LocalDateTime.of(2017, 7, 11, 20, 45, 5);

LocalDateTime有很多方法,可以获取年月日时分秒等日历信息,比如:

1
2
3
4
5
6
public int getYear()
public int getMonthValue()
public int getDayOfMonth()
public int getHour()
public int getMinute()
public int getSecond()

还可以获取星期几等信息,比如:

1
public DayOfWeek getDayOfWeek()

DayOfWeek是一个枚举,有7个取值,从DayOfWeek.MONDAY到DayOfWeek.SUN-DAY。

3. ZoneId/ZoneOffset

LocalDateTime不能直接转为时刻Instant,转换需要一个参数ZoneOffset,ZoneOffset表示相对于格林尼治的时区差,北京是+08:00。比如,转换一个LocalDateTime为北京的时刻,方法为:

1
2
3
public static Instant toBeijingInstant(LocalDateTime ldt) {
return ldt.toInstant(ZoneOffset.of("+08:00"));
}

给定一个时刻,使用不同时区解读,日历信息是不同的,Instant有方法根据时区返回一个ZonedDateTime:

1
public ZonedDateTime atZone(ZoneId zone)

默认时区是ZoneId.systemDefault(),可以这样构建ZoneId:

1
2
//北京时区
ZoneId bjZone = ZoneId.of("GMT+08:00")

ZoneOffset是ZoneId的子类,可以根据时区差构造。

4. LocalDate/LocalTime

可以认为LocalDateTime由两部分组成,一部分是日期LocalDate,另一部分是时间LocalTime。它们的用法也很直观,比如:

1
2
3
4
5
6
7
8
//表示2017年7月11日
LocalDate ld = LocalDate.of(2017, 7, 11);
//当前时刻按系统默认时区解读的日期
LocalDate now = LocalDate.now();
//表示21点10分34秒
LocalTime lt = LocalTime.of(21, 10, 34);
//当前时刻按系统默认时区解读的时间
LocalTime time = LocalTime.now();

LocalDateTime由LocalDate和LocalTime构成,LocalDate加上时间可以构成LocalDate-Time, LocalTime加上日期可以构成LocalDateTime,比如:

1
2
3
4
5
6
7
LocalDateTime ldt = LocalDateTime.of(2017, 7, 11, 20, 45, 5);
LocalDate ld = ldt.toLocalDate(); //2017-07-11
LocalTime lt = ldt.toLocalTime(); // 20:45:05
//LocalDate加上时间,结果为2017-07-11 21:18:39
LocalDateTime ldt2 = ld.atTime(21, 18, 39);
//LocalTime加上日期,结果为2016-03-24 20:45:05
LocalDateTime ldt3 = lt.atDate(LocalDate.of(2016, 3, 24));

5. ZonedDateTime

ZonedDateTime表示特定时区的日期和时间,获取系统默认时区的当前日期和时间,代码为:

1
ZonedDateTime zdt = ZonedDateTime.now();

LocalDateTime.now()也是获取默认时区的当前日期和时间,有什么区别呢?Local-DateTime内部不会记录时区信息,只会单纯记录年月日时分秒等信息,而ZonedDateTime除了记录日历信息,还会记录时区,它的其他大部分构建方法都需要显式传递时区,比如:

1
2
3
4
//根据Instant和时区构建ZonedDateTime
public static ZonedDateTime ofInstant(Instant instant, ZoneId zone)
//根据LocalDate、LocalTime和ZoneId构造
public static ZonedDateTime of(LocalDate date, LocalTime time, ZoneId zone)

ZonedDateTime可以直接转换为Instant,比如:

1
2
ZonedDateTime ldt = ZonedDateTime.now();
Instant now = ldt.toInstant();

26.5.2 格式化

Java 8中,主要的格式化类是java.time.format.DateTimeFormatter,它是线程安全的,看个例子:

1
2
3
4
DateTimeFormatter formatter = DateTimeFormatter.ofPattern(
"yyyy-MM-dd HH:mm:ss");
LocalDateTime ldt = LocalDateTime.of(2016,8,18,14,20,45);
System.out.println(formatter.format(ldt));

输出为:

1
2016-08-18 14:20:45

将字符串转化为日期和时间对象,可以使用对应类的parse方法,比如:

1
2
3
4
DateTimeFormatter formatter = DateTimeFormatter.ofPattern(
"yyyy-MM-dd HH:mm:ss");
String str = "2016-08-18 14:20:45";
LocalDateTime ldt = LocalDateTime.parse(str, formatter);

26.5.3 设置和修改时间

修改时期和时间有两种方式,一种是直接设置绝对值,另一种是在现有值的基础上进行相对增减操作,Java 8的大部分类都支持这两种方式。另外,Java 8的大部分类都是不可变类,修改操作是通过创建并返回新对象来实现的,原对象本身不会变。我们来看一些例子。

调整时间为下午3点20分,代码为:

1
2
LocalDateTime ldt = LocalDateTime.now();
ldt = ldt.withHour(15).withMinute(20).withSecond(0).withNano(0);

还可以为:

1
2
LocalDateTime ldt = LocalDateTime.now();
ldt = ldt.toLocalDate().atTime(15, 20);

3小时5分钟后,示例代码为:

1
2
LocalDateTime ldt = LocalDateTime.now();
ldt = ldt.plusHours(3).plusMinutes(5);

LocalDateTime有很多plusXXX和minusXXX方法,分别用于相对增加和减少时间。

今天0点,可以为:

1
2
LocalDateTime ldt = LocalDateTime.now();
ldt = ldt.with(ChronoField.MILLI_OF_DAY, 0);

ChronoField是一个枚举,里面定义了很多表示日历的字段,MILLI_OF_DAY表示在一天中的毫秒数,值从0到(24 * 60 * 60 * 1000)-1。还可以为:

1
LocalDateTime ldt = LocalDateTime.of(LocalDate.now(), LocalTime.MIN);

LocalTime.MIN表示”00:00”。也可以为:

1
LocalDateTime ldt = LocalDate.now().atTime(0, 0);

下周二上午10点整,可以为:

1
2
3
LocalDateTime ldt = LocalDateTime.now();
ldt = ldt.plusWeeks(1).with(ChronoField.DAY_OF_WEEK, 2)
.with(ChronoField.MILLI_OF_DAY, 0).withHour(10);

上面下周二指定是下周,如果是下一个周二呢?这与当前是周几有关,如果当前是周一,则下一个周二就是明天,而其他情况则是下周,代码可以为:

1
2
3
4
5
LocalDate ld = LocalDate.now();
if(! ld.getDayOfWeek().equals(DayOfWeek.MONDAY)){
ld = ld.plusWeeks(1);
}
LocalDateTime ldt = ld.with(ChronoField.DAY_OF_WEEK, 2).atTime(10, 0);

针对这种复杂一点的调整,Java 8有一个专门的接口TemporalAdjuster,这是一个函数式接口,定义为:

1
2
3
public interface TemporalAdjuster {
Temporal adjustInto(Temporal temporal);
}

Temporal是一个接口,表示日期或时间对象,Instant、LocalDateTime和LocalDate等都实现了它,这个接口就是对日期或时间进行调整,还有一个专门的类TemporalAdjusters,里面提供了很多TemporalAdjuster的实现。比如,针对下一个周几的调整,方法是:

1
public static TemporalAdjuster next(DayOfWeek dayOfWeek)

针对上面的例子,代码可以为:

1
2
3
LocalDate ld = LocalDate.now();
LocalDateTime ldt = ld.with(TemporalAdjusters.next(
DayOfWeek.TUESDAY)).atTime(10, 0);

这个next方法是怎么实现的呢?看代码:

1
2
3
4
5
6
7
8
public static TemporalAdjuster next(DayOfWeek dayOfWeek) {
int dowValue = dayOfWeek.getValue();
return (temporal) -> {
int calDow = temporal.get(DAY_OF_WEEK);
int daysDiff = calDow - dowValue;
return temporal.plus(daysDiff >= 0 ? 7 - daysDiff : -daysDiff, DAYS);
};
}

它内部封装了一些条件判断和具体调整,提供了更为易用的接口。

TemporalAdjusters中还有很多方法,部分方法如下:

1
2
3
4
5
6
public static TemporalAdjuster firstDayOfMonth()
public static TemporalAdjuster lastDayOfMonth()
public static TemporalAdjuster firstInMonth(DayOfWeek dayOfWeek)
public static TemporalAdjuster lastInMonth(DayOfWeek dayOfWeek)
public static TemporalAdjuster previous(DayOfWeek dayOfWeek)
public static TemporalAdjuster nextOrSame(DayOfWeek dayOfWeek)

这些方法的含义比较直观,就不解释了。它们主要是封装了日期和时间调整的一些基本操作,更为易用。
明天最后一刻,代码可以为:

1
2
LocalDateTime ldt = LocalDateTime.of(
LocalDate.now().plusDays(1), LocalTime.MAX);

或者为:

1
LocalDateTime ldt = LocalTime.MAX.atDate(LocalDate.now().plusDays(1));

本月最后一天最后一刻,代码可以为:

1
2
LocalDateTime ldt =   LocalDate.now()
.with(TemporalAdjusters.lastDayOfMonth()).atTime(LocalTime.MAX);

lastDayOfMonth()是怎么实现的呢?看代码:

1
2
3
4
public static TemporalAdjuster lastDayOfMonth() {
return(temporal) -> temporal.with(DAY_OF_MONTH,
temporal.range(DAY_OF_MONTH).getMaximum());
}

这里使用了range方法,从它的返回值可以获取对应日历单位的最大最小值,展开,本月最后一天最后一刻的代码还可以为:

1
2
3
4
long maxDayOfMonth = LocalDate.now().range(
ChronoField.DAY_OF_MONTH).getMaximum();
LocalDateTime ldt = LocalDate.now()
.withDayOfMonth((int)maxDayOfMonth).atTime(LocalTime.MAX);

下个月第一个周一的下午5点整,代码可以为:

1
2
LocalDateTime ldt = LocalDate.now().plusMonths(1)
.with(TemporalAdjusters.firstInMonth(DayOfWeek.MONDAY)).atTime(17, 0);

26.5.4 时间段的计算

Java 8中表示时间段的类主要有两个:Period和Duration。Period表示日期之间的差,用年月日表示,不能表示时间;Duration表示时间差,用时分秒等表示,也可以用天表示,一天严格等于24小时,不能用年月表示。下面看一些例子。
计算两个日期之间的差,看个Period的例子:

1
2
3
4
5
LocalDate ld1 = LocalDate.of(2016, 3, 24);
LocalDate ld2 = LocalDate.of(2017, 7, 12);
Period period = Period.between(ld1, ld2);
System.out.println(period.getYears() + "年"
+ period.getMonths() + "月" + period.getDays() + "天");

输出为:

1
1年3月18天

根据生日计算年龄,示例代码可以为:

1
2
LocalDate born = LocalDate.of(1990,06,20);
int year = Period.between(born, LocalDate.now()).getYears();

计算迟到分钟数,假定早上9点是上班时间,过了9点算迟到,迟到要统计迟到的分钟数,怎么计算呢?看代码:

1
2
long lateMinutes = Duration.between(LocalTime.of(9,0),
LocalTime.now()).toMinutes();

26.5.5 与Date/Calendar对象的转换

Java 8的日期和时间API没有提供与老的Date/Calendar相互转换的方法,但在实际中,我们可能是需要的。前面介绍了Date可以与Instant通过毫秒数相互转换,对于其他类型,也可以通过毫秒数/Instant相互转换。比如,将LocalDateTime按默认时区转换为Date,代码可以为:

1
2
3
4
public static Date toDate(LocalDateTime ldt){
return new Date(ldt.atZone(ZoneId.systemDefault())
.toInstant().toEpochMilli());
}

将ZonedDateTime转换为Calendar,代码可以为:

1
2
3
4
5
6
public static Calendar toCalendar(ZonedDateTime zdt) {
TimeZone tz = TimeZone.getTimeZone(zdt.getZone());
Calendar calendar = Calendar.getInstance(tz);
calendar.setTimeInMillis(zdt.toInstant().toEpochMilli());
return calendar;
}

Calendar保持了ZonedDateTime的时区信息。

将Date按默认时区转换为LocalDateTime,代码可以为:

1
2
3
4
public static LocalDateTime toLocalDateTime(Date date) {
return LocalDateTime.ofInstant(Instant.ofEpochMilli(date.getTime()),
ZoneId.systemDefault());
}

将Calendar转换为ZonedDateTime,代码可以为:

1
2
3
4
5
6
public static ZonedDateTime toZonedDateTime(Calendar calendar) {
ZonedDateTime zdt = ZonedDateTime.ofInstant(
Instant.ofEpochMilli(calendar.getTimeInMillis()),
calendar.getTimeZone().toZoneId());
return zdt;
}

至此,关于Java 8的日期和时间API就介绍完了。相比以前版本的Date和Calendar,它引入了更多的类,但概念更为清晰,更为强大和易用。

本章介绍了Java 8引入的Lambda表达式、函数式编程,以及日期和时间API,利用本章介绍的内容,我们可以在更高的抽象层次上思考和解决问题,包括处理集合数据、管理异步任务、操作日期和时间等。

26.4 组合式异步编程

前面两节讨论了Java 8中的函数式数据处理,那是对容器类的增强,它可以将对集合数据的多个操作以流水线的方式组合在一起。本节继续讨论Java 8的新功能,主要是一个新的类CompletableFuture,它是对并发编程的增强,它可以方便地将多个有一定依赖关系的异步任务以流水线的方式组合在一起,大大简化多异步任务的开发

之前介绍了那么多并发编程的内容,还有什么问题不能解决?CompletableFuture到底能解决什么问题?与之前介绍的内容有什么关系?具体如何使用?基本原理是什么?本节进行详细讨论,我们先来看它要解决的问题。

26.4.1 异步任务管理

在现代软件开发中,系统功能越来越复杂,管理复杂度的方法就是分而治之,系统的很多功能可能会被切分为小的服务,对外提供Web API,单独开发、部署和维护。比如,在一个电商系统中,可能有专门的产品服务、订单服务、用户服务、推荐服务、优惠服务、搜索服务等,在对外具体展示一个页面时,可能要调用多个服务,而多个调用之间可能还有一定的依赖。比如,显示一个产品页面,需要调用产品服务,也可能需要调用推荐服务获取与该产品有关的其他推荐,还可能需要调用优惠服务获取该产品相关的促销优惠,而为了调用优惠服务,可能需要先调用用户服务以获取用户的会员级别。

另外,现代软件经常依赖很多第三方服务,比如地图服务、短信服务、天气服务、汇率服务等,在实现一个具体功能时,可能要访问多个这样的服务,这些访问之间可能存在着一定的依赖关系。

为了提高性能,充分利用系统资源,这些对外部服务的调用一般都应该是异步的、尽量并发的。我们之前介绍过异步任务执行服务,使用ExecutorService可以方便地提交单个独立的异步任务,可以方便地在需要的时候通过Future接口获取异步任务的结果,但对于多个尤其是有一定依赖关系的异步任务,这种支持就不够了。

于是,就有了CompletableFuture,它是一个具体的类,实现了两个接口,一个是Future,另一个是CompletionStage。Future表示异步任务的结果,而CompletionStage的字面意思是完成阶段。多个CompletionStage可以以流水线的方式组合起来,对于其中一个CompletionStage,它有一个计算任务,但可能需要等待其他一个或多个阶段完成才能开始,它完成后,可能会触发其他阶段开始运行。CompletionStage提供了大量方法,使用它们,可以方便地响应任务事件,构建任务流水线,实现组合式异步编程。

具体怎么使用呢?下面我们会逐步说明,CompletableFuture也是一个Future,我们先来看与Future类似的地方。

26.4.2 与Future/FutureTask对比

我们先通过示例来简要回顾下异步任务执行服务和Future。

1.基本的任务执行服务

在异步任务执行服务中,用Callable或Runnable表示任务。以Callable为例,一个模拟的外部任务为:

1
2
3
4
5
6
7
8
9
10
11
12
13
private static Random rnd = new Random();
static int delayRandom(int min, int max) {
int milli = max > min ? rnd.nextInt(max - min) : 0;
try {
Thread.sleep(min + milli);
} catch (InterruptedException e) {
}
return milli;
}
static Callable<Integer> externalTask = () -> {
int time = delayRandom(20, 2000);
return time;
};

externalTask表示外部任务,我们使用了Lambda表达式,delayRandom用于模拟延时。

假定有一个异步任务执行服务,其代码为:

1
2
private static ExecutorService executor =
Executors.newFixedThreadPool(10);

通过任务执行服务调用外部服务,一般返回Future,表示异步结果,示例代码为:

1
2
3
public static Future<Integer> callExternalService(){
return executor.submit(externalTask);
}

在主程序中,结合异步任务和本地调用的示例代码为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public static void master() {
//执行异步任务
Future<Integer> asyncRet = callExternalService();
//执行其他任务……
//获取异步任务的结果,处理可能的异常
try {
Integer ret = asyncRet.get();
System.out.println(ret);
} catch (InterruptedException e) {
e.printStackTrace();
} catch (ExecutionException e) {
e.printStackTrace();
}
}

2.基本的CompletableFuture

使用CompletableFuture可以实现类似功能,不过,它不支持使用Callable表示异步任务,而支持Runnable和Supplier。Supplier替代Callable表示有返回结果的异步任务,与Callable的区别是,它不能抛出受检异常,如果会发生异常,可以抛出运行时异常。

使用Supplier表示异步任务,代码与Callable类似,替换变量类型即可:

1
2
3
4
static Supplier<Integer> externalTask = () -> {
int time = delayRandom(20, 2000);
return time;
};

使用CompletableFuture调用外部服务的代码可以为:

1
2
3
public static Future<Integer> callExternalService(){
return CompletableFuture.supplyAsync(externalTask, executor);
}

supplyAsync是一个静态方法,其定义为:

1
2
public static <U> CompletableFuture<U> supplyAsync(
Supplier<U> supplier, Executor executor)

它接受两个参数supplier和executor,使用executor执行supplier表示的任务,返回一个CompletableFuture,调用后,任务被异步执行,这个方法立即返回。

supplyAsync还有一个不带executor参数的方法:

1
public static <U> CompletableFuture<U> supplyAsync(Supplier<U> supplier)

没有executor,任务被谁执行呢?与系统环境和配置有关,一般来说,如果可用的CPU核数大于2,会使用Java 7引入的Fork/Join任务执行服务,即ForkJoinPool.common-Pool(),该任务执行服务背后的工作线程数一般为CPU核数减1,即Runtime.getRuntime(). availableProcessors()-1,否则,会使用ThreadPerTaskExecutor,它会为每个任务创建一个线程。

对于CPU密集型的运算任务,使用Fork/Join任务执行服务是合适的,但对于一般的调用外部服务的异步任务,Fork/Join可能是不合适的,因为它的并行度比较低,可能会让本可以并发的多任务串行运行,这时,应该提供Executor参数。

后面我们还会看到很多以Async结尾命名的方法,一般都有两个版本,一个带Executor参数,另一个不带,其含义是相同的,就不再重复介绍了。

对于类型为Runnable的任务,构建CompletableFuture的方法为:

1
2
3
4
public static CompletableFuture<Void> runAsync(
Runnable runnable)
public static CompletableFuture<Void> runAsync(
Runnable runnable, Executor executor)

它与supplyAsync是类似的,具体就不赘述了。

3. CompletableFuture对Future的基本增强

Future有的接口,CompletableFuture都是支持的,不过,CompletableFuture还有一些额外的相关方法,比如:

1
2
3
public T join()
public boolean isCompletedExceptionally()
public T getNow(T valueIfAbsent)

join与get方法类似,也会等待任务结束,但它不会抛出受检异常。如果任务异常结束了,join会将异常包装为运行时异常CompletionException抛出。

Future有isDone方法检查任务是否结束了,但不知道任务是正常结束还是异常结束, isCompletedExceptionally方法可以判断任务是否是异常结束。

getNow与join类似,区别是,如果任务还没有结束,getNow不会等待,而是会返回传入的参数valueIfAbsent。

4.进一步理解Future/CompletableFuture

前面例子都使用了任务执行服务,其实,任务执行服务与异步结果Future不是绑在一起的,可以自己创建线程返回异步结果。为进一步理解,我们看些示例。

使用FutureTask调用外部服务,代码可以为:

1
2
3
4
5
6
7
8
9
public static Future<Integer> callExternalService() {
FutureTask<Integer> future = new FutureTask<>(externalTask);
new Thread() {
public void run() {
future.run();
}
}.start();
return future;
}

内部自己创建了一个线程,线程调用FutureTask的run方法。我们之前分析过Future-Task的代码,run方法会调用externalTask的call方法,并保存结果或碰到的异常,唤醒等待结果的线程。

使用CompletableFuture,也可以直接创建线程,并返回异步结果,代码可以为:

1
2
3
4
5
6
7
8
9
10
11
12
13
public static Future<Integer> callExternalService() {
CompletableFuture<Integer> future = new CompletableFuture<>();
new Thread() {
public void run() {
try {
future.complete(externalTask.get());
} catch (Exception e) {
future.completeExceptionally(e);
}
}
}.start();
return future;
}

这里使用了CompletableFuture的两个方法:

1
2
public boolean complete(T value)
public boolean completeExceptionally(Throwable ex)

这两个方法显式设置任务的状态和结果,complete设置任务成功完成,结果为value, completeExceptionally设置任务异常结束,异常为ex。Future接口没有对应的方法,Future-Task有相关方法但不是public的(是protected的)。设置完后,它们都会触发其他依赖它们的CompletionStage。具体会触发什么呢?我们接下来再看。

26.4.3 响应结果或异常

使用Future,我们只能通过get获取结果,而get可能会需要阻塞等待,而通过Com-pletionStage,可以注册回调函数,当任务完成或异常结束时自动触发执行。有两类注册方法:whenComplete和handle,我们分别介绍。

1. whenComplete

whenComplete的声明为:

1
2
public CompletableFuture<T> whenComplete(
BiConsumer<? super T, ? super Throwable> action)

参数action表示回调函数,不管前一个阶段是正常结束还是异常结束,它都会被调用,函数类型是BiConsumer,接受两个参数,第一个参数是正常结束时的结果值,第二个参数是异常结束时的异常,BiConsumer没有返回值。whenComplete的返回值还是CompletableFuture,它不会改变原阶段的结果,还可以在其上继续调用其他函数。看个简单的示例:

1
2
3
4
5
6
7
8
CompletableFuture.supplyAsync(externalTask).whenComplete((result, ex) -> {
if(result ! = null) {
System.out.println(result);
}
if(ex ! = null) {
ex.printStackTrace();
}
}).join();

result表示前一个阶段的结果,ex表示异常,只可能有一个不为null。

whenComplete注册的函数具体由谁执行呢?一般而言,这要看注册时任务的状态。如果注册时任务还没有结束,则注册的函数会由执行任务的线程执行,在该线程执行完任务后执行注册的函数;如果注册时任务已经结束了,则由当前线程(即调用注册函数的线程)执行。

如果不希望当前线程执行,避免可能的同步阻塞,可以使用其他两个异步注册方法:

1
2
3
4
public CompletableFuture<T> whenCompleteAsync(
BiConsumer<? super T, ? super Throwable> action)
public CompletableFuture<T> whenCompleteAsync(
BiConsumer<? super T, ? super Throwable> action, Executor executor)

与前面介绍的以Async结尾的方法一样,对第一个方法,注册函数action会由默认的任务执行服务(即ForkJoinPool.commonPool()或ThreadPerTaskExecutor)执行;对第二个方法,会由参数中指定的executor执行。

2. handle

whenComplete只是注册回调函数,不改变结果,它返回了一个CompletableFuture,但这个CompletableFuture的结果与调用它的CompletableFuture是一样的,还有一个类似的注册方法handle,其声明为:

1
2
public <U> CompletableFuture<U> handle(
BiFunction<? super T, Throwable, ? extends U> fn)

回调函数是一个BiFunction,也是接受两个参数,一个是正常结果,另一个是异常,但BiFunction有返回值,在handle返回的CompletableFuture中,结果会被BiFunction的返回值替代,即使原来有异常,也会被覆盖,比如:

1
2
3
4
5
6
7
String ret =
CompletableFuture.supplyAsync(()->{
throw new RuntimeException("test");
}).handle((result, ex)->{
return "hello";
}).join();
System.out.println(ret);

输出为”hello”。异步任务抛出了异常,但通过handle方法,改变了结果。

与whenComplete类似,handle也有对应的异步注册方法handleAsync,具体我们就不探讨了。

3. exceptionally

whenComplete和handle都是既响应正常完成也响应异常,如果只对异常感兴趣,可以使用exceptionally,其声明为:

1
2
public CompletableFuture<T> exceptionally(
Function<Throwable, ? extends T> fn)

它注册的回调函数是Function,接受的参数为异常,返回一个值,与handle类似,它也会改变结果,具体就不举例了。

除了响应结果和异常,使用CompletableFuture,可以方便地构建有多种依赖关系的任务流,我们先来看简单的依赖单一阶段的情况。

26.4.4 构建依赖单一阶段的任务流

我们来看几个相关的方法——thenRun、thenAccept/thenApply和thenCompose。

1. thenRun

在一个阶段正常完成后,执行下一个任务,看个简单示例:

1
2
3
4
Runnable taskA = () -> System.out.println("task A");
Runnable taskB = () -> System.out.println("task B");
Runnable taskC = () -> System.out.println("task C");
CompletableFuture.runAsync(taskA).thenRun(taskB).thenRun(taskC).join();

这里,有三个异步任务taskA、taskB和taskC,通过thenRun自然地描述了它们的依赖关系。thenRun是同步版本,有对应的异步版本thenRunAsync:

1
2
3
public CompletableFuture<Void> thenRunAsync(Runnable action)
public CompletableFuture<Void> thenRunAsync(Runnable action,
Executor executor)

在thenRun构建的任务流中,只有前一个阶段没有异常结束,下一个阶段的任务才会执行,如果前一个阶段发生了异常,所有后续阶段都不会运行,结果会被设为相同的异常,调用join会抛出运行时异常CompletionException。

2. thenAccept/thenApply

如果下一个任务需要前一个阶段的结果作为参数,可以使用thenAccept或thenApply方法:

1
2
3
4
public CompletableFuture<Void> thenAccept(
Consumer<? super T> action)
public <U> CompletableFuture<U> thenApply(
Function<? super T, ? extends U> fn)

thenAccept的任务类型是Consumer,它接受前一个阶段的结果作为参数,没有返回值。thenApply的任务类型是Function,接受前一个阶段的结果作为参数,返回一个新的值,这个值会成为thenApply返回的CompletableFuture的结果值。看个简单示例:

1
2
3
4
5
Supplier<String> taskA = () -> "hello";
Function<String, String> taskB = (t) -> t.toUpperCase();
Consumer<String> taskC = (t) -> System.out.println("consume: " + t);
CompletableFuture.supplyAsync(taskA)
.thenApply(taskB).thenAccept(taskC).join();

taskA的结果是”hello”,传递给了taskB, taskB转换结果为”HELLO”,再把结果给taskC, taskC进行了输出,所以输出为:

1
consume: HELLO

CompletableFuture中有很多名称带有run、accept或apply的方法,它们一般与任务的类型相对应,run与Runnable对应,accept与Consumer对应,apply与Function对应,后续就不赘述了。

3. thenCompose

与thenApply类似,还有一个方法thenCompose,声明为:

1
2
public <U> CompletableFuture<U> thenCompose(
Function<? super T, ? extends CompletionStage<U>> fn)

这个任务类型也是Function,也是接受前一个阶段的结果,返回一个新的结果。不过,这个转换函数fn的返回值类型是CompletionStage,也就是说,它的返回值也是一个阶段,如果使用thenApply,结果就会变为CompletableFuture<CompletableFuture<U>>,而使用thenCompose,会直接返回fn返回的CompletionStage。thenCompose与thenApply的区别就如同Stream API中flatMap与map的区别,看个简单的示例:

1
2
3
4
5
6
Supplier<String> taskA = () -> "hello";
Function<String, CompletableFuture<String>> taskB = (t) ->
CompletableFuture.supplyAsync(() -> t.toUpperCase());
Consumer<String> taskC = (t) -> System.out.println("consume: " + t);
CompletableFuture.supplyAsync(taskA)
.thenCompose(taskB).thenAccept(taskC).join();

以上代码中,taskB是一个转换函数,但它自己也执行了异步任务,返回类型也是CompletableFuture,所以使用了thenCompose。

26.4.5 构建依赖两个阶段的任务流

thenRun、thenAccept、thenApply和thenCompose用于在一个阶段完成后执行另一个任务,CompletableFuture还有一些方法用于在两个阶段都完成后执行另一个任务,方法是:

1
2
3
4
5
6
7
8
public CompletableFuture<Void> runAfterBoth(
CompletionStage<? > other, Runnable action
public <U, V> CompletableFuture<V> thenCombine(
CompletionStage<? extends U> other,
BiFunction<? super T, ? super U, ? extends V> fn)
public <U> CompletableFuture<Void> thenAcceptBoth(
CompletionStage<? extends U> other,
BiConsumer<? super T, ? super U> action)

runAfterBoth对应的任务类型是Runnable, thenCombine对应的任务类型是BiFunction,接受前两个阶段的结果作为参数,返回一个结果;thenAcceptBoth对应的任务类型是BiConsumer,接受前两个阶段的结果作为参数,但不返回结果。它们都有对应的异步和带Executor参数的版本,用于指定下一个任务由谁执行,具体就不赘述了。当前阶段和参数指定的另一个阶段other没有依赖关系,并发执行,当两个都执行结束后,开始执行指定的另一个任务。

看个简单的示例,任务A和B执行结束后,执行任务C合并结果,代码为:

1
2
3
4
5
6
7
Supplier<String> taskA = () -> "taskA";
CompletableFuture<String> taskB = CompletableFuture.supplyAsync(
() -> "taskB");
BiFunction<String, String, String> taskC = (a, b) -> a + ", " + b;
String ret = CompletableFuture.supplyAsync(taskA)
.thenCombineAsync(taskB, taskC).join();
System.out.println(ret);

输出为:

1
taskA, taskB

前面的方法要求两个阶段都完成后才执行下一个任务,如果只需要其中任意一个阶段完成,可以使用下面的方法:

1
2
3
4
5
6
public CompletableFuture<Void> runAfterEither(
CompletionStage<? > other, Runnable action)
public <U> CompletableFuture<U> applyToEither(
CompletionStage<? extends T> other, Function<? super T, U> fn)
public CompletableFuture<Void> acceptEither(
CompletionStage<? extends T> other, Consumer<? super T> action)

它们都有对应的异步和带Executor参数的版本,用于指定下一个任务由谁执行,具体就不赘述了。当前阶段和参数指定的另一个阶段other没有依赖关系,并发执行,只要其中一个执行完了,就会启动参数指定的另一个任务,具体就不赘述了。

26.4.6 构建依赖多个阶段的任务流

如果依赖的阶段不止两个,可以使用如下方法:

1
2
public static CompletableFuture<Void> allOf(CompletableFuture<? >... cfs)
public static CompletableFuture<Object> anyOf(CompletableFuture<? >... cfs)

它们是静态方法,基于多个CompletableFuture构建了一个新的CompletableFuture。

对于allOf,当所有子CompletableFuture都完成时,它才完成,如果有的Completable-Future异常结束了,则新的CompletableFuture的结果也是异常。不过,它并不会因为有异常就提前结束,而是会等待所有阶段结束,如果有多个阶段异常结束,新的Com-pletableFuture中保存的异常是最后一个的。新的CompletableFuture会持有异常结果,但不会保存正常结束的结果,如果需要,可以从每个阶段中获取。看个简单的示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
CompletableFuture<String> taskA = CompletableFuture.supplyAsync(() -> {
delayRandom(100, 1000);
return "helloA";
}, executor);
CompletableFuture<Void> taskB = CompletableFuture.runAsync(() -> {
delayRandom(2000, 3000);
}, executor);
CompletableFuture<Void> taskC = CompletableFuture.runAsync(() -> {
delayRandom(30, 100);
throw new RuntimeException("task C exception");
}, executor);
CompletableFuture.allOf(taskA, taskB, taskC).whenComplete((result, ex) -> {
if(ex ! = null) {
System.out.println(ex.getMessage());
}
if(! taskA.isCompletedExceptionally()) {
System.out.println("task A " + taskA.join());
}
});

taskC会首先异常结束,但新构建的CompletableFuture会等待其他两个阶段结束,都结束后,可以通过子阶段(如taskA)的方法检查子阶段的状态和结果。

对于anyOf返回的CompletableFuture,当第一个子CompletableFuture完成或异常结束时,它相应地完成或异常结束,结果与第一个结束的子CompletableFuture一样,具体就不举例了。

26.4.7 小结

本节介绍了Java 8中的组合式异步编程CompletableFuture:
1)它是对Future的增强,但可以响应结果或异常事件,有很多方法构建异步任务流。
2)根据任务由谁执行,一般有三类对应方法:名称不带Async的方法由当前线程或前一个阶段的线程执行,带Async但没有指定Executor的方法由默认Excecutor(Fork-JoinPool.commonPool()或ThreadPerTaskExecutor)执行,带Async且指定Executor参数的方法由指定的Executor执行。
3)根据任务类型,一般也有三类对应方法:名称带run的对应Runnable,带accept的对应Consumer,带apply的对应Function。

使用CompletableFuture,可以简洁自然地表达多个异步任务之间的依赖关系和执行流程,大大简化代码,提高可读性

26.3 函数式数据处理:强大方便的收集器

对于collect方法,前面只是演示了其最基本的应用,它还有很多强大的功能,比如,可以分组统计汇总,实现类似数据库查询语言SQL中的group by功能。具体都有哪些功能?有什么用?如何使用?基本原理是什么?让我们逐步进行探讨,先来进一步理解collect方法。

26.3.1 理解collect

在上节中,过滤得到90分以上的学生列表,代码是这样的:

1
2
List<Student> above90List = students.stream().filter(t->t.getScore()>90)
.collect(Collectors.toList());

最后的collect调用看上去很神奇,它到底是怎么把Stream转换为List<Student>的呢?先看下collect方法的定义:

1
<R, A> R collect(Collector<? super T, A, R> collector)

它接受一个收集器collector作为参数,类型是Collector,这是一个接口,它的定义基本上是:

1
2
3
4
5
6
7
public interface Collector<T, A, R> {
Supplier<A> supplier();
BiConsumer<A, T> accumulator();
BinaryOperator<A> combiner();
Function<A, R> finisher();
Set<Characteristics> characteristics();
}

在顺序流中,collect方法与这些接口方法的交互大概是这样的:

1
2
3
4
5
6
7
//首先调用工厂方法supplier创建一个存放处理状态的容器container,类型为A
A container = collector.supplier().get();
//对流中的每一个元素t,调用累加器accumulator,参数为累计状态container和当前元素t
for(T t : data)
collector.accumulator().accept(container, t);
//最后调用finisher对累计状态container进行可能的调整,类型转换(A转换为R),返回结果
return collector.finisher().apply(container);

combiner只在并行流中有用,用于合并部分结果。characteristics用于标示收集器的特征,Collector接口的调用者可以利用这些特征进行一些优化。Characteristics是一个枚举,有三个值:CONCURRENT、UNORDERED和IDENTITY_FINISH,它们的含义我们后面通过例子简要说明,目前可以忽略。

Collectors.toList()具体是什么呢?看下代码:

1
2
3
4
5
6
public static <T>
Collector<T, ? , List<T>> toList() {
return new CollectorImpl<>((Supplier<List<T>>) ArrayList::new, List::add,
(left, right) ->{ left.addAll(right); return left; },
CH_ID);
}

它的实现类是CollectorImpl,这是Collectors内部的一个私有类,实现很简单,主要就是定义了两个构造方法,接受函数式参数并赋值给内部变量。对toList来说:
1)supplier的实现是ArrayList::new,也就是创建一个ArrayList作为容器。
2)accumulator的实现是List::add,也就是将碰到的每一个元素加到列表中。
3)第三个参数是combiner,表示合并结果。
4)第四个参数CH_ID是一个静态变量,只有一个特征IDENTITY_FINISH,表示finisher没有什么事情可以做,就是把累计状态container直接返回。

也就是说,collect(Collectors.toList())背后的伪代码如下所示:

1
2
3
4
List<T> container = new ArrayList<>();
for(T t : data)
container.add(t);
return container;

26.3.2 容器收集器

与toList类似的容器收集器还有toSet、toCollection、toMap等,我们来进行介绍。

1. toSet

toSet的使用与toList类似,只是它可以排重,就不举例了。toList背后的容器是ArrayList, toSet背后的容器是HashSet,其代码为:

1
2
3
4
5
6
public static <T>
Collector<T, ? , Set<T>> toSet() {
return new CollectorImpl<>((Supplier<Set<T>>) HashSet::new, Set::add,
(left, right) -> { left.addAll(right); return left; },
CH_UNORDERED_ID);
}

CH_UNORDERED_ID是一个静态变量,它的特征有两个:一个是IDENTITY_FINISH,表示返回结果即为Supplier创建的HashSet;另一个是UNORDERED,表示收集器不会保留顺序,这也容易理解,因为背后容器是HashSet。

2. toCollection

toCollection是一个通用的容器收集器,可以用于任何Collection接口的实现类,它接受一个工厂方法Supplier作为参数,具体代码为:

1
2
3
4
5
6
public static <T, C extends Collection<T>>
Collector<T, ? , C> toCollection(Supplier<C> collectionFactory) {
return new CollectorImpl<>(collectionFactory, Collection<T>::add,
(r1, r2) -> { r1.addAll(r2); return r1; },
CH_ID);
}

比如,如果希望排重但又希望保留出现的顺序,可以使用LinkedHashSet,Collector可以这么创建:

1
Collectors.toCollection(LinkedHashSet::new)

3. toMap

toMap将元素流转换为一个Map,我们知道,Map有键和值两部分,toMap至少需要两个函数参数,一个将元素转换为键,另一个将元素转换为值,其基本定义为:

1
2
3
public static <T, K, U> Collector<T, ? , Map<K, U>> toMap(
Function<? super T, ? extends K> keyMapper,
Function<? super T, ? extends U> valueMapper)

返回结果为Map<K,U>, keyMapper将元素转换为键,valueMapper将元素转换为值。比如,将学生流转换为学生名称和分数的Map,代码可以为:

1
2
Map<String, Double> nameScoreMap = students.stream().collect(
Collectors.toMap(Student::getName, Student::getScore));

这里,Student::getName是keyMapper, Student::getScore是valueMapper。

实践中,经常需要将一个对象列表按主键转换为一个Map,以便以后按照主键进行快速查找,比如,假定Student的主键是id,希望转换学生流为学生id和学生对象的Map,代码可以为:

1
2
Map<String, Student> byIdMap = students.stream().collect(
Collectors.toMap(Student::getId, t -> t));

t->t是valueMapper,表示值就是元素本身。这个函数用得比较多,接口Function定义了一个静态函数identity表示它。也就是说,上面的代码可以替换为:

1
2
Map<String, Student> byIdMap = students.stream().collect(
Collectors.toMap(Student::getId, Function.identity()));

上面的toMap假定元素的键不能重复,如果有重复的,会抛出异常,比如:

1
2
Map<String, Integer> strLenMap = Stream.of("abc", "hello", "abc").collect(
Collectors.toMap(Function.identity(), t->t.length()));

希望得到字符串与其长度的Map,但由于包含重复字符串”abc”,程序会抛出异常。这种情况下,我们希望的是程序忽略后面重复出现的元素,这时,可以使用另一个toMap函数:

1
2
3
4
public static <T, K, U> Collector<T, ? , Map<K, U>> toMap(
Function<? super T, ? extends K> keyMapper,
Function<? super T, ? extends U> valueMapper,
BinaryOperator<U> mergeFunction)

相比前面的toMap,它接受一个额外的参数mergeFunction,它用于处理冲突,在收集一个新元素时,如果新元素的键已经存在了,系统会将新元素的值与键对应的旧值一起传递给mergeFunction得到一个值,然后用这个值给键赋值。

对于前面字符串长度的例子,新值与旧值其实是一样的,我们可以用任意一个值,代码可以为:

1
2
3
Map<String, Integer> strLenMap = Stream.of("abc", "hello", "abc").collect(
Collectors.toMap(Function.identity(),
t->t.length(), (oldValue, value)->value));

有时,我们可能希望合并新值与旧值,比如一个联系人列表,对于相同的联系人,我们希望合并电话号码,mergeFunction可以定义为:

1
BinaryOperator<String> mergeFunction = (oldPhone, phone)->oldPhone+", "+phone;

toMap还有一个更为通用的形式:

1
2
3
4
public static <T, K, U, M extends Map<K, U>> Collector<T, ? , M> toMap(
Function<? super T, ? extends K> keyMapper,
Function<? super T, ? extends U> valueMapper,
BinaryOperator<U> mergeFunction, Supplier<M> mapSupplier)

相比前面的toMap,多了一个mapSupplier,它是Map的工厂方法,对于前面的两个toMap,其mapSupplier其实是HashMap::new。我们知道,HashMap是没有任何顺序的,如果希望保持元素出现的顺序,可以替换为LinkedHashMap,如果希望收集的结果排序,可以使用TreeMap。

toMap主要用于顺序流,对于并发流,Collectors有专门的名为toConcurrentMap的收集器,它内部使用ConcurrentHashMap,用法类似,具体我们就不讨论了。

26.3.3 字符串收集器

除了将元素流收集到容器中,另一个常见的操作是收集为一个字符串。比如,获取所有的学生名称,用逗号连接起来,传统上代码看上去像这样:

1
2
3
4
5
6
7
8
StringBuilder sb = new StringBuilder();
for(Student t : students){
if(sb.length()>0){
sb.append(", ");
}
sb.append(t.getName());
}
return sb.toString();

针对这种常见的需求,Collectors提供了joining收集器,比如:

1
2
3
public static Collector<CharSequence, ? , String> joining()
public static Collector<CharSequence, ? , String> joining(
CharSequence delimiter, CharSequence prefix, CharSequence suffix)

第一个就是简单地把元素连接起来,第二个支持一个分隔符,还可以给整个结果字符串加前缀和后缀,比如:

1
2
3
String result = Stream.of("abc", "老马", "hello")
.collect(Collectors.joining(", ", "[", "]"));
System.out.println(result);

输出为:

1
[abc,老马,hello]

joining的内部也利用了StringBuilder。比如,第一个joining函数的代码为:

[插图]

supplier是StringBuilder::new, accumulator是StringBuilder::append, finisher是StringBuilder::toString, CH_NOID表示特征集为空。

26.3.4 分组

分组类似于数据库查询语言SQL中的group by语句,它将元素流中的每个元素分到一个组,可以针对分组再进行处理和收集。分组的功能比较强大,我们逐步来说明。

为便于举例,我们先修改下学生类Student,增加一个字段grade表示年级:

1
2
3
4
5
6
public static Collector<CharSequence, ? , String> joining() {
return new CollectorImpl<CharSequence, StringBuilder, String>(
StringBuilder::new, StringBuilder::append,
(r1, r2) -> { r1.append(r2); return r1; },
StringBuilder::toString, CH_NOID);
}

示例学生列表students改为:

1
2
3
4
5
public Student(String name, String grade, double score) {
this.name = name;
this.grade = grade;
this.score = score;
}

1.基本用法

最基本的分组收集器为:

1
2
3
4
static List<Student> students = Arrays.asList(new Student[] {
new Student("zhangsan", "1", 91d), new Student("lisi", "2", 89d),
new Student("wangwu", "1", 50d), new Student("zhaoliu", "2", 78d),
new Student("sunqi", "1", 59d)});

参数是一个类型为Function的分组器classifier,它将类型为T的元素转换为类型为K的一个值,这个值表示分组值,所有分组值一样的元素会被归为同一个组,放到一个列表中,所以返回值类型是Map<K, List<T>>。比如,将学生流按照年级进行分组,代码为:

1
2
public static <T, K> Collector<T, ? , Map<K, List<T>>>
groupingBy(Function<? super T, ? extends K> classifier)

学生会分为两组:第一组键为”1”,分组学生包括”zhangsan””wangwu”和”sunqi”;第二组键为”2”,分组学生包括”lisi” “zhaoliu”。这段代码基本等同于如下代码:

1
2
Map<String, List<Student>> groups = students.stream()
.collect(Collectors.groupingBy(Student::getGrade));

显然,使用groupingBy要简洁清晰得多,但它到底是怎么实现的呢?

2.基本原理

groupingBy的代码为:

1
2
3
4
5
6
7
8
9
10
Map<String, List<Student>> groups = new HashMap<>();
for(Student t : students) {
String key = t.getGrade();
List<Student> container = groups.get(key);
if(container == null) {
container = new ArrayList<>();
groups.put(key, container);
}
container.add(t);
}

它调用了第二个groupingBy方法,传递了toList收集器,其代码为:

1
2
3
4
public static <T, K> Collector<T, ? , Map<K, List<T>>>
groupingBy(Function<? super T, ? extends K> classifier) {
return groupingBy(classifier, toList());
}

这个方法接受一个下游收集器downstream作为参数,然后传递给下面更通用的函数:

1
2
3
4
5
public static <T, K, A, D> Collector<T, ? , Map<K, D>> groupingBy(
Function<? super T, ? extends K> classifier,
Collector<? super T, A, D> downstream) {
return groupingBy(classifier, HashMap::new, downstream);
}

classifier还是分组器,mapFactory是返回Map的工厂方法,默认是HashMap::new, downstream表示下游收集器,下游收集器负责收集同一个分组内元素的结果

对最通用的groupingBy函数返回的收集器,其收集元素的基本过程和伪代码为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
//先创建一个存放结果的Map
Map map = mapFactory.get();
for(T t : data) {
//对每一个元素,先分组
K key = classifier.apply(t);
//找存放分组结果的容器,如果没有,让下游收集器创建,并放到Map中
A container = map.get(key);
if(container == null) {
container = downstream.supplier().get();
map.put(key, container);
}
//将元素交给下游收集器(即分组收集器)收集
downstream.accumulator().accept(container, t);
}
//调用分组收集器的finisher方法,转换结果
for(Map.Entry entry : map.entrySet()) {
entry.setValue(downstream.finisher().apply(entry.getValue()));
}
return map;

在最基本的groupingBy函数中,下游收集器是toList,但下游收集器还可以是其他收集器,甚至是groupingBy,以构成多级分组。下面我们来看更多的示例。

3.分组计数、找最大/最小元素

将元素按一定标准分为多组,然后计算每组的个数,按一定标准找最大或最小元素,这是一个常见的需求。Collectors提供了一些对应的收集器,一般用作下游收集器,比如:

1
2
3
4
5
6
7
8
//计数
public static <T> Collector<T, ? , Long> counting()
//计算最大值
public static <T> Collector<T, ? , Optional<T>> maxBy(
Comparator<? super T> comparator)
//计算最小值
public static <T> Collector<T, ? , Optional<T>> minBy(
Comparator<? super T> comparator)

还有更为通用的名为reducing的归约收集器,我们就不介绍了。下面看一些例子。

为了便于使用Collectors中的方法,我们将其中的方法静态导入,即加入如下代码:

1
import static java.util.stream.Collectors.*;

统计每个年级的学生个数,代码可以为:

1
2
Map<String, Long> gradeCountMap = students.stream().collect(
groupingBy(Student::getGrade, counting()));

统计一个单词流中每个单词的个数,按出现顺序排序,代码可以为:

1
2
3
Map<String, Long> wordCountMap =
Stream.of("hello", "world", "abc", "hello").collect(
groupingBy(Function.identity(), LinkedHashMap::new, counting()));

获取每个年级分数最高的一个学生,代码可以为:

1
2
3
Map<String, Optional<Student>> topStudentMap = students.stream().collect(
groupingBy(Student::getGrade,
maxBy(Comparator.comparing(Student::getScore))));

需要说明的是,这个分组收集结果是Optional<Student>,而不是Student,这是因为maxBy处理的流可能是空流,但对我们的例子,这是不可能的。为了直接得到Student,可以使用Collectors的另一个收集器collectingAndThen,在得到Optional<Student>后调用Optional的get方法,如下所示:

1
2
3
Map<String, Student> topStudentMap = students.stream().collect(
groupingBy(Student::getGrade, collectingAndThen(
maxBy(Comparator.comparing(Student::getScore)), Optional::get)));

关于collectingAndThen,我们稍后再进一步讨论。

4.分组数值统计

除了基本的分组计数,还经常需要进行一些分组数值统计,比如求学生分数的和、平均分、最高分、最低分等、针对int、long和double类型,Collectors提供了专门的收集器,比如:

1
2
3
4
5
6
7
8
9
10
//求平均值,int和long也有类似方法
public static <T> Collector<T, ? , Double>
averagingDouble(ToDoubleFunction<? super T> mapper)
//求和,long和double也有类似方法
public static <T> Collector<T, ? , Integer>
summingInt(ToIntFunction<? super T> mapper)
//求多种汇总信息,int和double也有类似方法
//LongSummaryStatistics包括个数、最大值、最小值、和、平均值等多种信息
public static <T> Collector<T, ? , LongSummaryStatistics>
summarizingLong(ToLongFunction<? super T> mapper)

比如,按年级统计学生分数信息,代码可以为:

1
2
3
Map<String, DoubleSummaryStatistics> gradeScoreStat =
students.stream().collect(groupingBy(Student::getGrade,
summarizingDouble(Student::getScore)));

5.分组内的map

对于每个分组内的元素,我们感兴趣的可能不是元素本身,而是它的某部分信息。在Stream API中,Stream有map方法,可以将元素进行转换,Collectors也为分组元素提供了函数mapping,如下所示:

1
2
3
public static <T, U, A, R>
Collector<T, ? , R> mapping(Function<? super T, ? extends U> mapper,
Collector<? super U, A, R> downstream)

交给下游收集器downstream的不再是元素本身,而是应用转换函数mapper之后的结果。比如,对学生按年级分组,得到学生名称列表,代码可以为:

1
2
3
4
Map<String, List<String>> gradeNameMap =
students.stream().collect(groupingBy(Student::getGrade,
mapping(Student::getName, toList())));
System.out.println(gradeNameMap);

输出为:

1
{1=[zhangsan, wangwu, sunqi], 2=[lisi, zhaoliu]}

Stream有flatMap方法。Java 9为Collectors增加了分组内的flatMap方法flatMapping,它与mapping的关系如同Stream中flatMap和map的关系,这里就不举例了,其定义为:

1
2
3
public static <T, U, A, R> Collector<T, ? , R> flatMapping(
Function<? super T, ? extends Stream<? extends U>> mapper,
Collector<? super U, A, R> downstream)

6.分组结果处理(filter/sort/skip/limit)

对分组后的元素,我们可以计数,找最大/最小元素,计算一些数值特征,还可以转换(map)后再收集,那可不可以像Stream API一样,排序(sort)、过滤(filter)、限制返回元素(skip/limit)呢?Collector没有专门的收集器,但有一个通用的方法:

1
2
public static<T, A, R, RR> Collector<T, A, RR> collectingAndThen(
Collector<T, A, R> downstream, Function<R, RR> finisher)

这个方法接受一个下游收集器downstream和一个finisher,返回一个收集器,它的主要代码为:

1
2
3
return new CollectorImpl<>(downstream.supplier(),
downstream.accumulator(), downstream.combiner(),
downstream.finisher().andThen(finisher), characteristics);

也就是说,它在下游收集器的结果上又调用了finisher。利用这个finisher,我们可以实现多种功能,下面看一些例子。收集完再排序,可以定义如下方法:

1
2
3
4
5
6
7
public static <T> Collector<T, ? , List<T>> collectingAndSort(
Collector<T, ? , List<T>> downstream, Comparator<? super T> comparator) {
return Collectors.collectingAndThen(downstream, (r) -> {
r.sort(comparator);
return r;
});
}

将学生按年级分组,分组内的学生按照分数由高到低进行排序,利用这个方法,代码可以为:

1
2
3
Map<String, List<Student>> gradeStudentMap = students.stream()
.collect(groupingBy(Student::getGrade, collectingAndSort(toList(),
Comparator.comparing(Student::getScore).reversed())));

针对这个需求,也可以先对流进行排序,然后再分组。

收集完再过滤,可以定义如下方法:

1
2
3
4
5
6
public static <T> Collector<T, ? , List<T>> collectingAndFilter(
Collector<T, ? , List<T>> downstream, Predicate<T> predicate) {
return Collectors.collectingAndThen(downstream, (r) -> {
return r.stream().filter(predicate).collect(Collectors.toList());
});
}

将学生按年级分组,分组后,每个分组只保留不及格的学生(低于60分),利用这个方法,代码可以为:

1
2
3
Map<String, List<Student>> gradeStudentMap = students.stream()
.collect(groupingBy(Student::getGrade,
collectingAndFilter(toList(), t->t.getScore()<60)));

Java 9中,Collectors增加了一个新方法filtering,可以实现相同的功能,定义为:

1
2
public static <T, A, R> Collector<T, ? , R> filtering(
Predicate<? super T> predicate, Collector<? super T, A, R> downstream)

用法如下:

1
2
3
Map<String, List<Student>> gradeStudentMap = students.stream()
.collect(groupingBy(Student::getGrade,
filtering(t->t.getScore()<60, toList()));

你可能会认为,实现这种效果也可以先对整个流进行过滤,然后再分组,比如这样:

1
2
3
Map<String, List<Student>> gradeStudentMap = students.stream()
.filter(t->t.getScore()<60)
.collect(groupingBy(Student::getGrade, toList()));

需要说明的是,这两种方式的结果可能是不一样的,如果是先过滤,那些没有任何元素的分组就不会出现在结果中,而如果是先分组,即使该组内的元素都被过滤了,组也会出现在最终结果中,只是分组结果为一个空的集合。

收集完,只返回特定区间的结果,可以定义如下方法:

1
2
3
4
5
6
7
public static <T> Collector<T, ? , List<T>> collectingAndSkipLimit(
Collector<T, ? , List<T>> downstream, long skip, long limit) {
return Collectors.collectingAndThen(downstream, (r) -> {
return r.stream().skip(skip).limit(limit)
.collect(Collectors.toList());
});
}

比如,将学生按年级分组,分组后,每个分组只保留前两名的学生,代码可以为:

1
2
3
4
Map<String, List<Student>> gradeStudentMap = students.stream()
.sorted(Comparator.comparing(Student::getScore).reversed())
.collect(groupingBy(Student::getGrade,
collectingAndSkipLimit(toList(), 0, 2)));

这次,我们先对学生流进行了排序,然后再进行了分组。

mapping和collectingAndThen都接受一个下游收集器,mapping在把元素交给下游收集器之前先进行转换,而collectingAndThen对下游收集器的结果进行转换,组合利用它们,可以构造更为灵活强大的收集器。

7.分区

分组的一个特殊情况是分区,就是将流按true/false分为两个组,Collectors有专门的分区函数:

1
2
3
4
5
public static <T> Collector<T, ? , Map<Boolean, List<T>>>
partitioningBy(Predicate<? super T> predicate)
public static <T, D, A> Collector<T, ? , Map<Boolean, D>>
partitioningBy(Predicate<? super T> predicate,
Collector<? super T, A, D> downstream)

第一个函数的下游收集器为toList(),第二个函数可以指定一个下游收集器。比如,将学生按照是否及格(大于等于60分)分为两组,代码可以为:

1
2
Map<Boolean, List<Student>> byPass = students.stream().collect(
partitioningBy(t->t.getScore()>=60));

按是否及格分组后,计算每个分组的平均分,代码可以为:

1
2
Map<Boolean, Double> avgScoreMap = students.stream().collect(
partitioningBy(t->t.getScore()>=60, averagingDouble(Student::getScore)));

8.多级分组

groupingBy和partitioningBy都可以接受一个下游收集器,对同一个分组或分区内的元素进行进一步收集,而下游收集器又可以是分组或分区,以构建多级分组。比如,按年级对学生分组,分组后,再按照是否及格对学生进行分区,代码可以为:

1
2
3
Map<String, Map<Boolean, List<Student>>> multiGroup = students.stream()
.collect(groupingBy(Student::getGrade,
partitioningBy(t->t.getScore()>=60)));

至此,关于函数式数据处理Stream API就介绍完了,Stream API提供了集合数据处理的常用函数,利用它们,可以简洁地实现大部分常见需求,大大减少代码,提高可读性

26.2 函数式数据处理:基本用法

上一节介绍了Lambda表达式和函数式接口,本节探讨它们的应用:函数式数据处理,针对常见的集合数据处理,Java 8引入了一套新的类库,位于包java.util.stream下,称为Stream API。这套API操作数据的思路不同于我们之前介绍的容器类API,它们是函数式的,非常简洁、灵活、易读。具体有什么不同呢?本节先介绍一些基本的API,下节讨论一些高级功能。

接口Stream类似于一个迭代器,但提供了更为丰富的操作,Stream API的主要操作就定义在该接口中。Java 8给Collection接口增加了两个默认方法,它们可以返回一个Stream,如下所示:

1
2
3
4
5
6
default Stream<E> stream() {
return StreamSupport.stream(spliterator(), false);
}
default Stream<E> parallelStream() {
return StreamSupport.stream(spliterator(), true);
}

stream()返回的是一个顺序流,parallelStream()返回的是一个并行流。顺序流就是由一个线程执行操作。而并行流背后可能有多个线程并行执行,与之前介绍的并发技术不同,使用并行流不需要显式管理线程,使用方法与顺序流是一样的。

下面我们主要针对顺序流学习Stream接口,包括其用法和基本原理,随后我们再介绍并行流,先来看一些简单的示例。

26.2.1 基本示例

上一节演示时使用了学生类Student和学生列表List<Student> lists,本节继续使用它们,看一些基本的过滤、转换以及过滤和转换组合的例子。

1.基本过滤

返回学生列表中90分以上的,传统上的代码一般是这样:

1
2
3
4
5
6
List<Student> above90List = new ArrayList<>();
for(Student t : students) {
if(t.getScore() > 90) {
above90List.add(t);
}
}

使用Stream API,代码可以这样:

1
2
List<Student> above90List = students.stream()
.filter(t->t.getScore()>90).collect(Collectors.toList());

先通过stream()得到一个Stream对象,然后调用Stream上的方法,filter()过滤得到90分以上的,它的返回值依然是一个Stream,为了转换为List,调用了collect方法并传递了一个Collectors.toList(),表示将结果收集到一个List中。

代码更为简洁易读了,这种数据处理方式称为函数式数据处理。与传统代码相比,其特点是:
1)没有显式的循环迭代,循环过程被Stream的方法隐藏了。
2)提供了声明式的处理函数,比如filter,它封装了数据过滤的功能,而传统代码是命令式的,需要一步步的操作指令。
3)流畅式接口,方法调用链接在一起,清晰易读。

2.基本转换

根据学生列表返回名称列表,传统上的代码一般是这样:

1
2
3
4
List<String> nameList = new ArrayList<>(students.size());
for(Student t : students) {
nameList.add(t.getName());
}

使用Stream API,代码可以这样:

1
2
List<String> nameList = students.stream()
.map(Student::getName).collect(Collectors.toList());

这里使用了Stream的map函数,它的参数是一个Function函数式接口,这里传递了方法引用。

3.基本的过滤和转换组合

返回90分以上的学生名称列表,传统上的代码一般是这样:

1
2
3
4
5
6
List<String> nameList = new ArrayList<>();
for(Student t : students) {
if(t.getScore() > 90) {
nameList.add(t.getName());
}
}

使用函数式数据处理的思路,可以将这个问题分解为由两个基本函数实现:
1)过滤:得到90分以上的学生列表。
2)转换:将学生列表转换为名称列表。

使用Stream API,可以将基本函数filter()和map()结合起来,代码可以这样:

1
2
3
List<String> above90Names = students.stream()
.filter(t->t.getScore()>90).map(Student::getName)
.collect(Collectors.toList());

这种组合利用基本函数、声明式实现集合数据处理功能的编程风格,就是函数式数据处理。

代码更为直观易读了,但你可能会担心它的性能有问题。filter()和map()都需要对流中的每个元素操作一次,一起使用会不会就需要遍历两次呢?答案是否定的,只需要一次。实际上,调用filter()和map()都不会执行任何实际的操作,它们只是在构建操作的流水线,调用collect才会触发实际的遍历执行,在一次遍历中完成过滤、转换以及收集结果的任务

像filter和map这种不实际触发执行、用于构建流水线、返回Stream的操作称为中间操作(intermediate operation),而像collect这种触发实际执行、返回具体结果的操作称为终端操作(terminal operation)。Stream API中还有更多的中间和终端操作,下面我们具体介绍。

26.2.2 中间操作

除了filter和map, Stream API的中间操作还有distinct、sorted、skip、limit、peek、mapToLong、mapToInt、mapToDouble、flatMap等,我们逐个介绍。

1. distinct

distinct返回一个新的Stream,过滤重复的元素,只留下唯一的元素,是否重复是根据equals方法来比较的,distinct可以与其他函数(如filter、map)结合使用。比如,返回字符串列表中长度小于3的字符串、转换为小写、只保留唯一的,代码可以为:

1
2
3
4
List<String> list = Arrays.asList(new String[]{"abc", "def", "hello", "Abc"});
List<String> retList = list.stream()
.filter(s->s.length()<=3).map(String::toLowerCase).distinct()
.collect(Collectors.toList());

虽然都是中间操作,但distinct与filter和map是不同的。filter和map都是无状态的,对于流中的每一个元素,处理都是独立的,处理后即交给流水线中的下一个操作;distinct不同,它是有状态的,在处理过程中,它需要在内部记录之前出现过的元素,如果已经出现过,即重复元素,它就会过滤掉,不传递给流水线中的下一个操作。对于顺序流,内部实现时,distinct操作会使用HashSet记录出现过的元素,如果流是有顺序的,需要保留顺序,会使用LinkedHashSet。

2. sorted

有两个sorted方法:

1
2
Stream<T> sorted()
Stream<T> sorted(Comparator<? super T> comparator)

它们都对流中的元素排序,都返回一个排序后的Stream。第一个方法假定元素实现了Comparable接口,第二个方法接受一个自定义的Comparator。比如,过滤得到90分以上的学生,然后按分数从高到低排序,分数一样的按名称排序,代码为:

1
2
3
4
List<Student> list = students.stream().filter(t->t.getScore()>90)
.sorted(Comparator.comparing(Student::getScore)
.reversed().thenComparing(Student::getName))
.collect(Collectors.toList());

这里,使用了Comparator的comparing、reversed和thenComparing构建了Comparator。

与distinct一样,sorted也是一个有状态的中间操作,在处理过程中,需要在内部记录出现过的元素。其不同是,每碰到流中的一个元素,distinct都能立即做出处理,要么过滤,要么马上传递给下一个操作;sorted需要先排序,为了排序,它需要先在内部数组中保存碰到的每一个元素,到流结尾时再对数组排序,然后再将排序后的元素逐个传递给流水线中的下一个操作。

3. skip/limit

它们的定义为:

1
2
Stream<T> skip(long n)
Stream<T> limit(long maxSize)

skip跳过流中的n个元素,如果流中元素不足n个,返回一个空流,limit限制流的长度为maxSize。比如,将学生列表按照分数排序,返回第3名到第5名,代码为:

1
2
3
List<Student> list = students.stream()
.sorted(Comparator.comparing(Student::getScore).reversed())
.skip(2).limit(3).collect(Collectors.toList());

skip和limit都是有状态的中间操作。对前n个元素,skip的操作就是过滤,对后面的元素,skip就是传递给流水线中的下一个操作。limit的一个特点是:它不需要处理流中的所有元素,只要处理的元素个数达到maxSize,后面的元素就不需要处理了,这种可以提前结束的操作称为短路操作

skip和limit只能根据元素数目进行操作,Java 9增加了两个新方法,相当于更为通用的skip和limit:

1
2
3
4
//通用的skip,在谓词返回为true的情况下一直进行skip操作,直到某次返回false
default Stream<T> dropWhile(Predicate<? super T> predicate)
//通用的limit,在谓词返回为true的情况下一直接受,直到某次返回false
default Stream<T> takeWhile(Predicate<? super T> predicate)

4. peek

peek的定义为:

1
Stream<T> peek(Consumer<? super T> action)

它返回的流与之前的流是一样的,没有变化,但它提供了一个Consumer,会将流中的每一个元素传给该Consumer。这个方法的主要目的是支持调试,可以使用该方法观察在流水线中流转的元素,比如:

1
2
3
List<String> above90Names = students.stream().filter(t->t.getScore()>90)
.peek(System.out::println).map(Student::getName)
.collect(Collectors.toList());

5. mapToLong/mapToInt/mapToDouble

map函数接受的参数是一个Function<T, R>,为避免装箱/拆箱,提高性能,Stream还有如下返回基本类型特定流的方法:

1
2
3
DoubleStream mapToDouble(ToDoubleFunction<? super T> mapper)
IntStream mapToInt(ToIntFunction<? super T> mapper)
LongStream mapToLong(ToLongFunction<? super T> mapper)

DoubleStream/IntStream/LongStream是基本类型特定的流,有一些专门的更为高效的方法。比如,求学生列表的分数总和,代码为:

1
double sum = students.stream().mapToDouble(Student::getScore).sum();

6. flatMap

flatMap的定义为:

1
<R> Stream<R> flatMap(Function<? super T, ? extends Stream<? extends R>> mapper)

它接受一个函数mapper,对流中的每一个元素,mapper会将该元素转换为一个流Stream,然后把新生成流的每一个元素传递给下一个操作。比如:

1
2
3
4
5
6
List<String> lines = Arrays.asList(new String[]{
"hello abc", "老马 编程"});
List<String> words = lines.stream()
.flatMap(line -> Arrays.stream(line.split("\\s+")))
.collect(Collectors.toList());
System.out.println(words);

这里的mapper将一行字符串按空白符分隔为了一个单词流,Arrays.stream可以将一个数组转换为一个流,输出为:

1
[hello, abc, 老马, 编程]

可以看出,实际上,flatMap完成了一个1到n的映射。

26.2.3 终端操作

中间操作不触发实际的执行,返回值是Stream,而终端操作触发执行,返回一个具体的值,除了collect, Stream API的终端操作还有max、min、count、allMatch、anyMatch、noneMatch、findFirst、findAny、forEach、toArray、reduce等,我们逐个介绍。

1. max/min

max/min的定义为:

1
2
Optional<T> max(Comparator<? super T> comparator)
Optional<T> min(Comparator<? super T> comparator)

它们返回流中的最大值/最小值,它们的返回值类型是Optional<T>,而不是T。

java.util.Optional是Java 8引入的一个新类,它是一个泛型容器类,内部只有一个类型为T的单一变量value,可能为null,也可能不为null。Optional有什么用呢?它用于准确地传递程序的语义,它清楚地表明,其代表的值可能为null,程序员应该进行适当的处理

Optional定义了一些方法,比如:

1
2
3
4
5
6
7
8
9
10
11
12
//value不为null时返回true
public boolean isPresent()
//返回实际的值,如果为null,抛出异常NoSuchElementException
public T get()
//如果value不为null,返回value,否则返回other
public T orElse(T other)
//构建一个空的Optional, value为null
public static<T> Optional<T> empty()
//构建一个非空的Optional, 参数value不能为null
public static <T> Optional<T> of(T value)
//构建一个Optional,参数value可以为null,也可以不为null
public static <T> Optional<T> ofNullable(T value)

在max/min的例子中,通过声明返回值为Optional,我们可以知道具体的返回值不一定存在,这发生在流中不含任何元素的情况下。

看个简单的例子,返回分数最高的学生,代码为:

1
2
Student student = students.stream()
.max(Comparator.comparing(Student::getScore).reversed()).get();

这里,假定students不为空。

2. count

count很简单,就是返回流中元素的个数。比如,统计大于90分的学生个数,代码为:

1
long above90Count = students.stream().filter(t->t.getScore()>90).count();

3. allMatch/anyMatch/noneMatch

这几个函数都接受一个谓词Predicate,返回一个boolean值,用于判定流中的元素是否满足一定的条件。它们的区别是:

  • allMatch:只有在流中所有元素都满足条件的情况下才返回true。
  • anyMatch:只要流中有一个元素满足条件就返回true。
  • noneMatch:只有流中所有元素都不满足条件才返回true。

如果流为空,那么这几个函数的返回值都是true。

比如,判断是不是所有学生都及格了(不小于60分),代码可以为:

1
boolean allPass = students.stream().allMatch(t->t.getScore()>=60);

这几个操作都是短路操作,不一定需要处理所有元素就能得出结果,比如,对于all-Match,只要有一个元素不满足条件,就能返回false。

4. findFirst/findAny

它们的定义为:

1
2
Optional<T> findFirst()
Optional<T> findAny()

它们的返回类型都是Optional,如果流为空,返回Optional.empty()。findFirst返回第一个元素,而findAny返回任一元素,它们都是短路操作。随便找一个不及格的学生,代码可以为:

1
2
3
4
5
Optional<Student> student = students.stream().filter(t->t.getScore()<60)
.findAny();
if(student.isPresent()){
//处理不及格的学生
}

5. forEach

有两个forEach方法:

1
2
void forEach(Consumer<? super T> action)
void forEachOrdered(Consumer<? super T> action)

它们都接受一个Consumer,对流中的每一个元素,传递元素给Consumer。区别在于:在并行流中,forEach不保证处理的顺序,而forEachOrdered会保证按照流中元素的出现顺序进行处理。

比如,逐行打印大于90分的学生,代码可以为:

1
students.stream().filter(t->t.getScore()>90).forEach(System.out::println);

6. toArray

toArray将流转换为数组,有两个方法:

1
2
Object[] toArray()
<A> A[] toArray(IntFunction<A[]> generator)

不带参数的toArray返回的数组类型为Object[],这通常不是期望的结果,如果希望得到正确类型的数组,需要传递一个类型为IntFunction的generator。IntFunction的定义为:

1
2
3
public interface IntFunction<R> {
R apply(int value);
}

generator接受的参数是流的元素个数,它应该返回对应大小的正确类型的数组。

比如,获取90分以上的学生数组,代码可以为:

1
2
Student[] above90Arr = students.stream().filter(t->t.getScore()>90)
.toArray(Student[]::new);

Student[]::new就是一个类型为IntFunction<Student[]>的generator。

7. reduce

reduce代表归约或者叫折叠,它是max/min/count的更为通用的函数,将流中的元素归约为一个值。有三个reduce函数:

1
2
3
4
Optional<T> reduce(BinaryOperator<T> accumulator);
T reduce(T identity, BinaryOperator<T> accumulator);
<U> U reduce(U identity, BiFunction<U, ? super T, U> accumulator,
BinaryOperator<U> combiner);

第一个reduce函数基本等同于调用:

1
2
3
4
5
6
7
8
9
10
11
boolean foundAny = false;
T result = null;
for(T element : this stream) {
if(! foundAny) {
foundAny = true;
result = element;
}
else
result = accumulator.apply(result, element);
}
return foundAny ? Optional.of(result) : Optional.empty();

比如,使用reduce函数求分数最高的学生,代码可以为:

1
2
3
4
5
6
7
Student topStudent = students.stream().reduce((accu, t) -> {
if(accu.getScore() >= t.getScore()) {
return accu;
} else {
return t;
}
}).get();

第二个reduce函数多了一个identity参数,表示初始值,它基本等同于调用:

1
2
3
4
T result = identity;
for(T element : this stream)
result = accumulator.apply(result, element)
return result;

第一个和第二个reduce函数的返回类型只能是流中元素的类型,而第三个reduce函数更为通用,它的归约类型可以自定义,另外,它多了一个combiner参数。combiner用在并行流中,用于合并子线程的结果。对于顺序流,它基本等同于调用:

1
2
3
4
U result = identity;
for(T element : this stream)
result = accumulator.apply(result, element)
return result;

注意与第二个reduce函数相区分,它的结果类型不是T,而是U。比如,使用reduce函数计算学生分数的和,代码可以为:

1
2
3
4
double sumScore = students.stream().reduce(0d,
(sum, t) -> sum += t.getScore(),
(sum1, sum2) -> sum1 += sum2
);

从以上可以看出,reduce函数虽然更为通用,但比较费解,难以使用,一般情况下应该优先使用其他函数。collect函数比reduce函数更为通用、强大和易用,关于它,我们稍后再详细介绍。

26.2.4 构建流

前面我们主要使用的是Collection的stream方法,换做parallelStream方法,就会使用并行流,接口方法都是通用的。但并行流内部会使用多线程,线程个数一般与系统的CPU核数一样,以充分利用CPU的计算能力。

进一步来说,并行流内部会使用Java 7引入的fork/join框架,即处理由fork和join两个阶段组成,fork就是将要处理的数据拆分为小块,多线程按小块进行并行计算,join就是将小块的计算结果进行合并,具体我们就不探讨了。使用并行流,不需要任何线程管理的代码,就能实现并行。

除了通过Collection接口的stream/parallelStream获取流,还有一些其他方式可以获取流。Arrays有一些stream方法,可以将数组或子数组转换为流,比如:

1
2
3
4
public static IntStream stream(int[] array)
public static DoubleStream stream(double[] array, int startInclusive,
int endExclusive)
public static <T> Stream<T> stream(T[] array)

输出当前目录下所有普通文件的名字,代码可以为:

1
2
3
File[] files = new File(".").listFiles();
Arrays.stream(files).filter(File::isFile).map(File::getName)
.forEach(System.out::println);

Stream也有一些静态方法,可以构建流,比如:

1
2
3
4
5
6
7
8
9
10
//返回一个空流
public static<T> Stream<T> empty()
//返回只包含一个元素t的流
public static<T> Stream<T> of(T t)
//返回包含多个元素values的流
public static<T> Stream<T> of(T... values)
//通过Supplier生成流,流的元素个数是无限的
public static<T> Stream<T> generate(Supplier<T> s)
//同样生成无限流,第一个元素为seed,第二个为f(seed),第三个为f(f(seed)),以此类推
public static<T> Stream<T> iterate(final T seed, final UnaryOperator<T> f)

输出10个随机数,代码可以为:

1
Stream.generate(()->Math.random()).limit(10).forEach(System.out::println);

输出100个递增的奇数,代码可以为:

1
Stream.iterate(1, t->t+2).limit(100).forEach(System.out::println);

26.2.5 函数式数据处理思维

可以看出,使用Stream API处理数据集合,与直接使用容器类API处理数据的思路是完全不一样的。流定义了很多数据处理的基本函数,对于一个具体的数据处理问题,解决的主要思路就是组合利用这些基本函数,以声明式的方式简洁地实现期望的功能,这种思路就是函数式数据处理思维,相比直接利用容器类API的命令式思维,思考的层次更高

Stream API的这种思路也不是新发明,它与数据库查询语言SQL是很像的,都是声明式地操作集合数据,很多函数都能在SQL中找到对应,比如filter对应SQL的where, sorted对应order by等。SQL一般都支持分组(group by)功能,StreamAPI也支持,但关于分组,我们下节再介绍。

Stream API也与各种基于Unix系统的管道命令类似。熟悉Unix系统的都知道,Unix有很多命令,大部分命令只是专注于完成一件事情,但可以通过管道的方式将多个命令链接起来,完成一些复杂的功能,比如:

1
cat nginx_access.log | awk '{print $1}' | sort | uniq -c | sort -rnk 1 | head -n 20

以上命令可以分析nginx访问日志,统计出访问次数最多的前20个IP地址及其访问次数。具体来说,cat命令输出nginx访问日志到流,一行为一个元素,awk输出行的第一列,这里为IP地址,sort按IP进行排序,”uniq -c”按IP统计计数,”sort -rnk 1”按计数从高到低排序,”head -n 20”输出前20行。

第26章 函数式编程

Java 8引入了一个重要新语法——Lambda表达式,它是一种紧凑的传递代码的方式,利用它,可以实现简洁灵活的函数式编程。

基于Lambda表达式,针对常见的集合数据处理,Java 8引入了一套新的类库,位于包java.util.stream下,称为Stream API。这套API操作数据的思路不同于我们之前介绍的容器类API,它们是函数式的,非常简洁、灵活、易读。

Stream API是对容器类的增强,它可以将对集合数据的多个操作以流水线的方式组合在一起。Java 8还增加了一个新的类CompletableFuture,它是对并发编程的增强,可以方便地将多个有一定依赖关系的异步任务以流水线的方式组合在一起,大大简化多异步任务的开发

利用Lambda表达式,Java 8还增强了日期和时间API。

本章就来介绍这些Java 8引入的函数式编程特性和API,具体分为5节:26.1节介绍Lambda表达式;26.2节介绍函数式数据处理的基本用法;26.3节重点讨论函数式数据处理中的收集器;26.4节介绍组合式异步编程CompletableFuture;26.5节介绍Java 8的日期和时间API。

26.1 Lambda表达式

Lambda表达式到底是什么?有什么用?本节进行详细探讨。Lambda这个名字来源于学术界的λ演算,具体我们就不探讨了。理解Lambda表达式,我们需要先回顾一下接口、匿名内部类和代码传递。

26.1.1 通过接口传递代码

我们之前介绍过接口以及面向接口的编程,针对接口而非具体类型进行编程,可以降低程序的耦合性,提高灵活性,提高复用性。接口常被用于传递代码,比如,我们知道File有如下方法:

1
public File[] listFiles(FilenameFilter filter)

listFiles需要的其实不是FilenameFilter对象,而是它包含的如下方法:

1
boolean accept(File dir, String name);

或者说,listFiles希望接受一段方法代码作为参数,但没有办法直接传递这个方法代码本身,只能传递一个接口。

再如,类Collections中的很多方法都接受一个参数Comparator,比如:

1
public static <T> void sort(List<T> list, Comparator<? super T> c)

它们需要的也不是Comparator对象,而是它包含的如下方法:

1
int compare(T o1, T o2);

但是,没有办法直接传递方法,只能传递一个接口。

又如,异步任务执行服务ExecutorService,提交任务的方法有:

1
2
<T> Future<T> submit(Callable<T> task);
Future<?> submit(Runnable task);

Callable和Runnable接口也用于传递任务代码。

通过接口传递行为代码,就要传递一个实现了该接口的实例对象,在之前的章节中,最简洁的方式是使用匿名内部类,比如:

1
2
3
4
5
6
7
8
9
10
11
//列出当前目录下的所有扩展名为.txt的文件
File f = new File(".");
File[] files = f.listFiles(new FilenameFilter(){
@Override
public boolean accept(File dir, String name) {
if(name.endsWith(".txt")){
return true;
}
return false;
}
});

将files按照文件名排序,代码为:

1
2
3
4
5
6
Arrays.sort(files, new Comparator<File>() {
@Override
public int compare(File f1, File f2) {
return f1.getName().compareTo(f2.getName());
}
});

提交一个最简单的任务,代码为:

1
2
3
4
5
6
7
ExecutorService executor = Executors.newFixedThreadPool(100);
executor.submit(new Runnable() {
@Override
public void run() {
System.out.println("hello world");
}
});

26.1.2 Lambda语法

Java 8提供了一种新的紧凑的传递代码的语法:Lambda表达式。对于前面列出文件的例子,代码可以改为:

1
2
3
4
5
6
7
File f = new File(".");
File[] files = f.listFiles((File dir, String name) -> {
if(name.endsWith(".txt")) {
return true;
}
return false;
});

可以看出,相比匿名内部类,传递代码变得更为直观,不再有实现接口的模板代码,不再声明方法,也没有名字,而是直接给出了方法的实现代码。Lambda表达式由->分隔为两部分,前面是方法的参数,后面{}内是方法的代码。上面的代码可以简化为:

1
2
3
File[] files = f.listFiles((File dir, String name) -> {
return name.endsWith(".txt");
});

当主体代码只有一条语句的时候,括号和return语句也可以省略,上面的代码可以变为:

1
File[] files = f.listFiles((File dir, String name) -> name.endsWith(".txt"));

注意:没有括号的时候,主体代码是一个表达式,这个表达式的值就是函数的返回值,结尾不能加分号,也不能加return语句。

方法的参数类型声明也可以省略,上面的代码还可以继续简化为:

1
File[] files = f.listFiles((dir, name) -> name.endsWith(".txt"));

之所以可以省略方法的参数类型,是因为Java可以自动推断出来,它知道listFiles接受的参数类型是FilenameFilter,这个接口只有一个方法accept,这个方法的两个参数类型分别是File和String。这样简化下来,代码是不是简洁多了?

排序的代码用Lambda表达式可以写为:

1
Arrays.sort(files, (f1, f2) -> f1.getName().compareTo(f2.getName()));

提交任务的代码用Lambda表达式可以写为:

1
executor.submit(()->System.out.println("hello"));

参数部分为空,写为()。

当参数只有一个的时候,参数部分的括号可以省略。比如,File还有如下方法:

1
public File[] listFiles(FileFilter filter)

FileFilter的定义为:

1
2
3
public interface FileFilter {
boolean accept(File pathname);
}

使用FileFilter重写上面的列举文件的例子,代码可以为:

1
File[] files = f.listFiles(path -> path.getName().endsWith(".txt"));

与匿名内部类类似,Lambda表达式也可以访问定义在主体代码外部的变量,但对于局部变量,它也只能访问final类型的变量,与匿名内部类的区别是,它不要求变量声明为final,但变量事实上不能被重新赋值。比如:

1
2
String msg = "hello world";
executor.submit(()->System.out.println(msg));

可以访问局部变量msg,但msg不能被重新赋值,如果这样写:

1
2
3
String msg = "hello world";
msg = "good morning";
executor.submit(()->System.out.println(msg));

Java编译器会提示错误。

这个原因与匿名内部类是一样的,Java会将msg的值作为参数传递给Lambda表达式,为Lambda表达式建立一个副本,它的代码访问的是这个副本,而不是外部声明的msg变量。如果允许msg被修改,则程序员可能会误以为Lambda表达式读到修改后的值,引起更多的混淆。

为什么非要建立副本,直接访问外部的msg变量不行吗?不行,因为msg定义在栈中,当Lambda表达式被执行的时候,msg可能早已被释放了。如果希望能够修改值,可以将变量定义为实例变量,或者将变量定义为数组,比如:

1
2
3
String[] msg = new String[]{"hello world"};
msg[0] = "good morning";
executor.submit(()->System.out.println(msg[0]));

从以上内容可以看出,Lambda表达式与匿名内部类很像,主要就是简化了语法,那它是不是语法糖,内部实现其实就是内部类呢?答案是否定的,Java会为每个匿名内部类生成一个类,但Lambda表达式不会。Lambda表达式通常比较短,为每个表达式生成一个类会生成大量的类,性能会受到影响。

内部实现上,Java利用了Java 7引入的为支持动态类型语言引入的invokedynamic指令、方法句柄(method handle)等,具体实现比较复杂,我们就不探讨了,感兴趣的读者可以参看http://cr.openjdk.java.net/~briangoetz/lambda/lambda-translation.html ,我们需要知道的是,Java的实现是非常高效的,不用担心生成太多类的问题。

Lambda表达式不是匿名内部类,那它的类型到底是什么呢?是函数式接口

26.1.3 函数式接口

Java 8引入了函数式接口的概念,函数式接口也是接口,但只能有一个抽象方法,前面提及的接口都只有一个抽象方法,都是函数式接口。之所以强调是“抽象”方法,是因为Java 8中还允许定义静态方法和默认方法。Lambda表达式可以赋值给函数式接口,比如:

1
2
3
4
5
FileFilter filter = path -> path.getName().endsWith(".txt");
FilenameFilter fileNameFilter = (dir, name) -> name.endsWith(".txt");
Comparator<File> comparator = (f1, f2) ->
f1.getName().compareTo(f2.getName());
Runnable task = () -> System.out.println("hello world");

如果看这些接口的定义,会发现它们都有一个注解@FunctionalInterface,比如:

1
2
3
4
@FunctionalInterface
public interface Runnable {
public abstract void run();
}

@FunctionalInterface用于清晰地告知使用者这是一个函数式接口,不过,这个注解不是必需的,不加,只要只有一个抽象方法,也是函数式接口。但如果加了,而又定义了超过一个抽象方法,Java编译器会报错,这类似于我们之前介绍的Override注解。

26.1.4 预定义的函数式接口

Java 8定义了大量的预定义函数式接口,用于常见类型的代码传递,这些函数定义在包java.util.function下,主要接口如表26-1所示。

表26-1 主要的预定义函数式接口

epub_923038_153
对于基本类型boolean、int、long和double,为避免装箱/拆箱,Java 8提供了一些专门的函数,比如,int相关的部分函数如表26-2所示。

表26-2 int类型的函数式接口

epub_923038_154
这些函数有什么用呢?它们被大量用于Java 8的函数式数据处理Stream相关的类中,即使不使用Stream,也可以在自己的代码中直接使用这些预定义的函数。我们看一些简单的示例,包括Predicate、Function和Consumer。

1. Predicate示例

为便于举例,我们先定义一个简单的学生类Student,它有name和score两个属性,如下所示。

1
2
3
4
static class Student {
String name;
double score;
}

我们省略了构造方法和getter/setter方法。

有一个学生列表:

1
2
3
List<Student> students = Arrays.asList(new Student[] {
new Student("zhangsan", 89d), new Student("lisi", 89d),
new Student("wangwu", 98d) });

在日常开发中,列表处理的一个常见需求是过滤,列表的类型经常不一样,过滤的条件也经常变化,但主体逻辑都是类似的,可以借助Predicate写一个通用的方法,如下所示:

1
2
3
4
5
6
7
8
9
public static <E> List<E> filter(List<E> list, Predicate<E> pred) {
List<E> retList = new ArrayList<>();
for(E e : list) {
if(pred.test(e)) {
retList.add(e);
}
}
return retList;
}

这个方法可以这么用:

1
2
//过滤90分以上的
students = filter(students, t -> t.getScore() > 90);

2. Function示例

列表处理的另一个常见需求是转换。比如,给定一个学生列表,需要返回名称列表,或者将名称转换为大写返回,可以借助Function写一个通用的方法,如下所示:

1
2
3
4
5
6
7
public static <T, R> List<R> map(List<T> list, Function<T, R> mapper) {
List<R> retList = new ArrayList<>(list.size());
for(T e : list) {
retList.add(mapper.apply(e));
}
return retList;
}

根据学生列表返回名称列表的代码为:

1
List<String> names = map(students, t -> t.getName());

将学生名称转换为大写的代码为:

1
2
students = map(students, t -> new Student(
t.getName().toUpperCase(), t.getScore()));

3. Consumer示例

在上面转换学生名称为大写的例子中,我们为每个学生创建了一个新的对象,另一种常见的情况是直接修改原对象,通过代码传递,这时,可以用Consumer写一个通用的方法,比如:

1
2
3
4
5
public static <E> void foreach(List<E> list, Consumer<E> consumer) {
for(E e : list) {
consumer.accept(e);
}
}

上面转换为大写的例子可以改为:

1
foreach(students, t -> t.setName(t.getName().toUpperCase()));

以上这些示例主要用于演示函数式接口的基本概念,实际中可以直接使用流API。

26.1.5 方法引用

Lambda表达式经常用于调用对象的某个方法,比如:

1
List<String> names = map(students, t -> t.getName());

这时,它可以进一步简化,如下所示:

1
List<String> names = map(students, Student::getName);

Student::getName这种写法是Java 8引入的一种新语法,称为方法引用。它是Lambda表达式的一种简写方法,由::分隔为两部分,前面是类名或变量名,后面是方法名。方法可以是实例方法,也可以是静态方法,但含义不同。

我们看一些例子,还是以Student为例,先增加一个静态方法:

1
2
3
public static String getCollegeName(){
return "Laoma School";
}

对于静态方法,如下两条语句是等价的:

1
2
1. Supplier<String> s = Student::getCollegeName;
2. Supplier<String> s = () -> Student.getCollegeName();

它们的参数都是空,返回类型为String。

而对于实例方法,它的第一个参数就是该类型的实例,比如,如下两条语句是等价的:

1
2
1. Function<Student, String> f = Student::getName;
2. Function<Student, String> f = (Student t) -> t.getName();

对于Student::setName,它是一个BiConsumer,即如下两条语句是等价的:

1
2
1. BiConsumer<Student, String> c = Student::setName;
2. BiConsumer<Student, String> c = (t, name) -> t.setName(name);

如果方法引用的第一部分是变量名,则相当于调用那个对象的方法。比如,假定t是一个Student类型的变量,则如下两条语句是等价的:

1
2
1. Supplier<String> s = t::getName;
2. Supplier<String> s = () -> t.getName();

下面两条语句也是等价的:

1
2
1. Consumer<String> consumer = t::setName;
2. Consumer<String> consumer = (name) -> t.setName(name);

对于构造方法,方法引用的语法是<类名>::new,如Student::new,即下面两条语句等价:

1
2
3
1. BiFunction<String, Double, Student> s = (name, score)
-> new Student(name, score);
2. BiFunction<String, Double, Student> s = Student::new;

26.1.6 函数的复合

在前面的例子中,函数式接口都用作方法的参数,其他部分通过Lambda表达式传递具体代码给它。函数式接口和Lambda表达式还可用作方法的返回值,传递代码回调用者,将这两种用法结合起来,可以构造复合的函数,使程序简洁易读

下面我们看一些例子,这些例子利用了Java 8对接口的增强,即静态方法和默认方法,并利用它们实现复合函数,包括Comparator接口和function包。

1. Comparator中的复合方法

Comparator接口定义了如下静态方法:

1
2
3
4
5
6
public static <T, U extends Comparable<? super U>> Comparator<T> comparing(
Function<? super T, ? extends U> keyExtractor) {
Objects.requireNonNull(keyExtractor);
return (Comparator<T> & Serializable)
(c1, c2) -> keyExtractor.apply(c1).compareTo(keyExtractor.apply(c2));
}

这个方法是什么意思呢?它用于构建一个Comparator,比如,在前面的例子中,对文件按照文件名排序的代码为:

1
Arrays.sort(files, (f1, f2) -> f1.getName().compareTo(f2.getName()));

使用comparing方法,代码可以简化为:

1
Arrays.sort(files, Comparator.comparing(File::getName));

这样,代码的可读性是不是大大增强了?comparing方法为什么能达到这个效果呢?它构建并返回了一个符合Comparator接口的Lambda表达式,这个Comparator接受的参数类型是File,它使用了传递过来的函数代码keyExtractor将File转换为String进行比较。像comparing这样使用复合方式构建并传递代码并不容易阅读,但调用者很方便,也很容易理解。

Comparator还有很多默认方法,我们看两个:

1
2
3
4
5
6
7
8
9
10
default Comparator<T> reversed() {
return Collections.reverseOrder(this);
}
default Comparator<T> thenComparing(Comparator<? super T> other) {
Objects.requireNonNull(other);
return (Comparator<T> & Serializable) (c1, c2) -> {
int res = compare(c1, c2);
return (res ! = 0) ? res : other.compare(c1, c2);
};
}

reversed返回一个新的Comparator,按原排序逆序排。thenComparing也返回一个新的Comparator,在原排序认为两个元素排序相同的时候,使用传递的Comparator other进行比较。

看一个使用的例子,将学生列表按照分数倒序排(高分在前),分数一样的按照名字进行排序:

1
2
3
students.sort(Comparator.comparing(Student::getScore)
.reversed()
.thenComparing(Student::getName));

这样,代码是不是很容易读?

2. function包中的复合方法

在java.util.function包的很多函数式接口里,都定义了一些复合方法,我们看一些例子。

Function接口有如下定义:

1
2
3
4
default <V> Function<T, V> andThen(Function<? super R, ? extends V> after) {
Objects.requireNonNull(after);
return (T t) -> after.apply(apply(t));
}

先将T类型的参数转化为类型R,再调用after将R转换为V,最后返回类型V。

还有如下定义:

1
2
3
4
5
default <V> Function<V, R> compose(
Function<? super V, ? extends T> before) {
Objects.requireNonNull(before);
return (V v) -> apply(before.apply(v));
}

对V类型的参数,先调用before将V转换为T类型,再调用当前的apply方法转换为R类型返回。

Consumer、Predicate等都有一些复合方法,它们大量用于函数式数据处理API中,具体我们就不探讨了。

26.1.7 小结

本节介绍了Java 8中的一些新概念,包括Lambda表达式、函数式接口和方法引用等。

最重要的变化是,传递代码变得简单了,函数变为了代码世界的“一等公民”,可以方便地被作为参数传递,被作为返回值,被复合利用以构建新的函数,看上去,这些只是语法上的一些小变化,但利用这些小变化,却能使得代码更为通用、更为灵活、更为简洁易读,这大概就是函数式编程的奇妙之处。

25.4 剖析常见表达式

本节来讨论和分析一些常用的正则表达式,具体包括:

  • 邮编。
  • 电话号码,包括手机号码和固定电话号码。
  • 日期和时间。
  • 身份证号。
  • IP地址。
  • URL。
  • Email地址。
  • 中文字符。

对于同一个目的,正则表达式往往有多种写法,大多没有唯一正确的写法,本节的写法主要是示例。此外,写一个正则表达式,匹配希望匹配的内容往往比较容易,但让它不匹配不希望匹配的内容则往往比较困难,也就是说,保证精确性经常是很难的,不过,很多时候,也没有必要写完全精确的表达式,需要写到多精确与需要处理的文本和需求有关。另外,正则表达式难以表达的,可以通过写程序进一步处理。这么描述可能比较抽象,下面,我们会具体讨论分析。

1.邮编

邮编比较简单,就是6位数字,所以表达式可以为:

1
[0-9]{6}

这个表达式可以用于验证输入是否为邮编,比如:

1
2
3
4
public static Pattern ZIP_CODE_PATTERN = Pattern.compile("[0-9]{6}");
public static boolean isZipCode(String text) {
return ZIP_CODE_PATTERN.matcher(text).matches();
}

但如果用于查找,这个表达式是不够的,看个例子:

1
2
3
4
5
6
7
8
9
public static void findZipCode(String text) {
Matcher matcher = ZIP_CODE_PATTERN.matcher(text);
while (matcher.find()) {
System.out.println(matcher.group());
}
}
public static void main(String[] args) {
findZipCode("邮编 100013,电话18612345678");
}

文本中只有一个邮编,但输出却为:

1
2
100013
186123

这怎么办呢?可以使用环视边界匹配,对于左边界,它前面的字符不能是数字,环视表达式为:

1
(?<![0-9])

对于右边界,它右边的字符不能是数字,环视表达式为:

1
(?![0-9])

所以,完整的表达式可以为:

1
(?<![0-9])[0-9]{6}(?![0-9])

使用这个表达式,将ZIP_CODE_PATTERN改为:

1
2
3
4
public static Pattern ZIP_CODE_PATTERN = Pattern.compile(
"(? <! [0-9])" //左边不能有数字
+ "[0-9]{6}"
+ "(? ! [0-9])"); //右边不能有数字

就可以输出期望的结果了。6位数字就一定是邮编吗?答案当然是否定的,所以,这个表达式也不是精确的,如果需要更精确的验证,可以写程序进一步检查。

2.手机号码

中国的手机号码都是11位数字,所以,最简单的表达式就是:

1
[0-9]{11}

不过,目前手机号第1位都是1,第2位取值为3、4、5、7、8之一,所以更精确的表达式是:

1
1[34578][0-9]{9}

为方便表达手机号,手机号中间经常有连字符(即减号’-‘),形如:

1
186-1234-5678

为表达这种可选的连字符,表达式可以改为:

1
1[34578][0-9]-? [0-9]{4}-? [0-9]{4}

在手机号前面,可能还有0、+86或0086,和手机号码之间可能还有一个空格,比如:

1
2
3
018612345678
+86 18612345678
0086 18612345678

为表达这种形式,可以在号码前加如下表达式:

1
((0|\+86|0086)\s? )?

和邮编类似,如果为了抽取,也要在左右加环视边界匹配,左右不能是数字。所以,完整的表达式为:

1
(? <! [0-9])((0|\+86|0086)\s? )?1[34578][0-9]-? [0-9]{4}-? [0-9]{4}(? ! [0-9])

用Java表示的代码为:

1
2
3
4
5
public static Pattern MOBILE_PHONE_PATTERN = Pattern.compile(
"(? <! [0-9])" //左边不能有数字
+ "((0|\\+86|0086)\\s? )? " // 0 +86 0086
+ "1[34578][0-9]-? [0-9]{4}-? [0-9]{4}" // 186-1234-5678
+ "(? ! [0-9])"); //右边不能有数字

3.固定电话号码

不考虑分机,中国的固定电话一般由两部分组成:区号和市内号码,区号是3到4位,市内号码是7到8位。区号以0开头,表达式可以为:

1
0[0-9]{2,3}

市内号码表达式为:

1
[0-9]{7,8}

区号可能用括号包含,区号与市内号码之间可能有连字符,如以下形式:

1
2
010-62265678
(010)62265678

整个区号是可选的,所以整个表达式为:

1
(\(?0[0-9]{2,3}\)?-?)?[0-9]{7,8}

再加上左右边界环视,完整的Java表示为:

1
2
3
4
5
public static Pattern FIXED_PHONE_PATTERN = Pattern.compile(
"(? <! [0-9])" //左边不能有数字
+ "(\\(?0[0-9]{2,3}\\)? -? )? " //区号
+ "[0-9]{7,8}"//市内号码
+ "(? ! [0-9])"); //右边不能有数字

4.日期

日期的表示方式有很多种,我们只看一种,形如:

1
2
2017-06-21
2016-11-1

年月日之间用连字符分隔,月和日可能只有一位。最简单的正则表达式可以为:

1
\d{4}-\d{1,2}-\d{1,2}

年一般没有限制,但月只能取值1~12,日只能取值1~31,怎么表达这种限制呢?

对于月,有两种情况,1月到9月,表达式可以为:

1
0?[1-9]

10月到12月,表达式可以为:

1
1[0-2]

所以,月的表达式为:

1
(0?[1-9]|1[0-2])

对于日,有三种情况:

  • 1到9号,表达式为:0?[1-9]
  • 10号到29号,表达式为:[1-2][0-9]
  • 30号和31号,表达式为:3[01]

所以,整个表达式为:

1
\d{4}-(0?[1-9]|1[0-2])-(0?[1-9]|[1-2][0-9]|3[01])

加上左右边界环视,完整的Java表示为:

1
2
3
4
5
6
public static Pattern DATE_PATTERN = Pattern.compile(
"(? <! [0-9])" //左边不能有数字
+ "\\d{4}-" //年
+ "(0? [1-9]|1[0-2])-" //月
+ "(0? [1-9]|[1-2][0-9]|3[01])"//日
+ "(? ! [0-9])"); //右边不能有数字

5.时间

考虑24小时制,只考虑小时和分钟,小时和分钟都用固定两位表示,格式如下:

1
10:57

基本表达式为:

1
\d{2}:\d{2}

小时取值范围为0~23,更精确的表达式为:

1
([0-1][0-9]|2[0-3])

分钟取值范围为0~59,更精确的表达式为:

1
[0-5][0-9]

所以,整个表达式为:

1
([0-1][0-9]|2[0-3]):[0-5][0-9]

加上左右边界环视,完整的Java表示为:

1
2
3
4
5
public static Pattern TIME_PATTERN = Pattern.compile(
"(? <! [0-9])" // 左边不能有数字
+ "([0-1][0-9]|2[0-3])" // 小时
+ ":" + "[0-5][0-9]"// 分钟
+ "(? ! [0-9])"); // 右边不能有数字

6.身份证号

身份证有一代和二代之分,一代身份证号是15位数字,二代身份证号是18位数字,都不能以0开头。对于二代身份证号,最后一位可能为x或X,其他是数字。一代身份证号表达式可以为:

1
[1-9][0-9]{14}

二代身份证号表达式可以为:

1
[1-9][0-9]{16}[0-9xX]

这两个表达式的前面部分是相同的,二代身份证号表达式多了如下内容:

1
[0-9]{2}[0-9xX]

所以,它们可以合并为一个表达式,即:

1
[1-9][0-9]{14}([0-9]{2}[0-9xX])?

加上左右边界环视,完整的Java表示为:

1
2
3
4
5
public static Pattern ID_CARD_PATTERN = Pattern.compile(
"(? <! [0-9])" //左边不能有数字
+ "[1-9][0-9]{14}" //一代身份证
+ "([0-9]{2}[0-9xX])? " //二代身份证多出的部分
+ "(? ! [0-9])"); //右边不能有数字

符合这个要求的就一定是身份证号吗?当然不是,身份证号还有一些更为具体的要求,本书就不探讨了。

7. IP地址

IP地址示例如下:

1
192.168.3.5

点号分隔,4段数字,每个数字范围是0~255。最简单的表达式为:

1
(\d{1,3}\.){3}\d{1-3}

\d{1,3}太简单,没有满足0~255之间的约束,要满足这个约束,需要分多种情况考虑。

值是1位数,前面可能有0~2个0,表达式为:

1
0{0,2}[0-9]

值是两位数,前面可能有一个0,表达式为:

1
0?[0-9]{2}

值是三位数,又要分为多种情况。以1开头的,后两位没有限制,表达式为:

1
1[0-9]{2}

以2开头的,如果第二位是0到4,则第三位没有限制,表达式为:

1
2[0-4][0-9]

如果第二位是5,则第三位取值为0到5,表达式为:

1
25[0-5]

所以,\d{1,3}更为精确的表示为:

1
(0{0,2}[0-9]|0? [0-9]{2}|1[0-9]{2}|2[0-4][0-9]|25[0-5])

所以,加上左右边界环视,IP地址的完整Java表示为:

1
2
3
4
5
public static Pattern IP_PATTERN = Pattern.compile(
"(? <! [0-9])" //左边不能有数字
+ "((0{0,2}[0-9]|0? [0-9]{2}|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.){3}"
+ "(0{0,2}[0-9]|0? [0-9]{2}|1[0-9]{2}|2[0-4][0-9]|25[0-5])"
+ "(? ! [0-9])"); //右边不能有数字

8. URL

URL的格式比较复杂,其规范定义在https://tools.ietf.org/html/rfc1738,我们只考虑HTTP协议,其通用格式是:

1
http://<host>:<port>/<path>? <searchpart>

开始是http://,接着是主机名,主机名之后是可选的端口,再之后是可选的路径,路径后是可选的查询字符串,以?开头。看一些例子:

1
2
3
http://www.example.com
http://www.example.com/ab/c/def.html
http://www.example.com:8080/ab/c/def? q1=abc&q2=def

主机名中的字符可以是字母、数字、减号和点号,所以表达式可以为:

1
[-0-9a-zA-Z.]+

端口部分可以写为:

1
(:\d+)?

路径由多个子路径组成,每个子路径以/开头,后跟零个或多个非/的字符,简单地说,表达式可以为:

1
(/[^/]*)*

更精确地说,把所有允许的字符列出来,表达式为:

1
(/[-\w$.+! *'(), %; :@&=]*)*

对于查询字符串,简单地说,由非空字符串组成,表达式为:

1
\?[\S]*

更精确的,把所有允许的字符列出来,表达式为:

1
\?[-\w$.+!*'(),%;:@&=]*

路径和查询字符串是可选的,且查询字符串只有在至少存在一个路径的情况下才能出现,其模式为:

1
(/<sub_path>(/<sub_path>)*(\?<search>)?)?

所以,路径和查询部分的简单表达式为:

1
(/[^/]*(/[^/]*)*(\? [\S]*)?)?

精确表达式为:

1
(/[-\w$.+!*'(),%;:@&=]*(/[-\w$.+!*'(),%;:@&=]*)*(\?[-\w$.+!*'(),%;:@&=]*)?)?

HTTP的完整Java表达式为:

1
2
3
4
5
6
7
8
public static Pattern HTTP_PATTERN = Pattern.compile(
"http://" + "[-0-9a-zA-Z.]+" //主机名
+ "(:\\d+)? " //端口
+ "(" //可选的路径和查询 - 开始
+ "/[-\\w$.+! *'(), %; :@&=]*" //第一层路径
+ "(/[-\\w$.+! *'(), %; :@&=]*)*" //可选的其他层路径
+ "(\\? [-\\w$.+! *'(), %; :@&=]*)? " //可选的查询字符串
+ ")? "); //可选的路径和查询 - 结束

9. Email地址

完整的Email规范比较复杂,定义在https://tools.ietf.org/html/rfc822,我们先看一些实际中常用的。比如新浪邮箱:

1
abc@sina.com

对于用户名部分,它的要求是:4~16个字符,可使用英文小写、数字、下画线,但下画线不能在首尾。怎么验证用户名呢?可以为:

1
[a-z0-9][a-z0-9_]{2,14}[a-z0-9]

新浪邮箱的完整Java表达式为:

1
2
3
4
public static Pattern SINA_EMAIL_PATTERN = Pattern.compile(
"[a-z0-9]"
+ "[a-z0-9_]{2,14}"
+ "[a-z0-9]@sina\\.com");

我们再来看QQ邮箱,它对于用户名的要求为:

1)3~18个字符,可使用英文、数字、减号、点或下画线;
2)必须以英文字母开头,必须以英文字母或数字结尾;
3)点、减号、下画线不能连续出现两次或两次以上。

如果只有第1条,可以为:

1
[-0-9a-zA-Z._]{3,18}

为满足第2条,可以改为:

1
[a-zA-Z][-0-9a-zA-Z._]{1,16}[a-zA-Z0-9]

怎么满足第3条呢?可以使用边界环视,左边加如下表达式:

1
(?![-0-9a-zA-Z._]*(--|\.\.|__))

完整表达式可以为:

1
(?![-0-9a-zA-Z._]*(--|\.\.|__))[a-zA-Z][-0-9a-zA-Z._]{1,16}[a-zA-Z0-9]

QQ邮箱的完整Java表达式为:

1
2
3
4
5
6
public static Pattern QQ_EMAIL_PATTERN = Pattern.compile(
//点、减号、下画线不能连续出现两次或两次以上
"(? ! [-0-9a-zA-Z._]*(--|\\.\\.|__))"
+ "[a-zA-Z]" //必须以英文字母开头
+ "[-0-9a-zA-Z._]{1,16}" //3~18位 英文、数字、减号、点、下画线组成
+ "[a-zA-Z0-9]@qq\\.com"); //由英文字母、数字结尾

以上都是特定邮箱服务商的要求,一般的邮箱是什么规则呢?一般而言,以@作为分隔符,前面是用户名,后面是域名。用户名的一般规则是:

  • 由英文字母、数字、下画线、减号、点号组成;
  • 至少1位,不超过64位;
  • 开头不能是减号、点号和下画线。

比如:

1
h_llo-abc.good@example.com

这个表达式可以为:

1
[0-9a-zA-Z][-._0-9a-zA-Z]{0,63}

域名部分以点号分隔为多个部分,至少有两个部分。最后一部分是顶级域名,由2~3个英文字母组成,表达式可以为:

1
[a-zA-Z]{2,3}

对于域名的其他点号分隔的部分,每个部分一般由字母、数字、减号组成,但减号不能在开头,长度不能超过63个字符,表达式可以为:

1
[0-9a-zA-Z][-0-9a-zA-Z]{0,62}

所以,域名部分的表达式为:

1
([0-9a-zA-Z][-0-9a-zA-Z]{0,62}\.)+[a-zA-Z]{2,3}

完整的Java表示为:

1
2
3
4
5
public static Pattern GENERAL_EMAIL_PATTERN = Pattern.compile(
"[0-9a-zA-Z][-._0-9a-zA-Z]{0,63}" //用户名
+ "@"
+ "([0-9a-zA-Z][-0-9a-zA-Z]{0,62}\\.)+" //域名部分
+ "[a-zA-Z]{2,3}"); //顶级域名

10.中文字符

中文字符的Unicode编号一般位于\u4e00~\u9fff之间,所以匹配任意一个中文字符的表达式可以为:

1
[\u4e00-\u9fff]

Java表达式为:

1
2
public static Pattern CHINESE_PATTERN = Pattern.compile(
"[\\u4e00-\\u9fff]");

11.小结

本节详细讨论和分析了一些常见的正则表达式。在实际开发中,有些可以直接使用,有些需要根据具体文本和需求进行调整。完整的代码在Github上,地址为https://github.com/swiftma/program-logic ,位于包shuo.laoma.regex.c90下。

至此,关于正则表达式就介绍完了。下一章,我们探讨Java 8中的函数式编程。

25.3 模板引擎

利用Java API尤其是Matcher中的几个方法,我们可以实现一个简单的模板引擎。模板是一个字符串,中间有一些变量,以{name}表示,比如:

1
String template = "Hi {name}, your code is {code}.";

这里,模板字符串中有两个变量:一个是name,另一个是code。变量的实际值通过Map提供,变量名称对应Map中的键,模板引擎的任务就是接受模板和Map作为参数,返回替换变量后的字符串,示例实现为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
private static Pattern templatePattern = Pattern.compile("\\{(\\w+)\\}");
public static String templateEngine(String template,
Map<String, Object> params) {
StringBuffer sb = new StringBuffer();
Matcher matcher = templatePattern.matcher(template);
while(matcher.find()) {
String key = matcher.group(1);
Object value = params.get(key);
matcher.appendReplacement(sb, value ! = null
Matcher.quoteReplacement(value.toString()) : "");
}
matcher.appendTail(sb);
return sb.toString();
}

代码寻找所有的模板变量,正则表达式为:

1
\{(\w+)\}

{‘是元字符,所以要转义。\w+表示变量名,为便于引用,加了括号,可以通过分组1引用变量名。

使用该模板引擎的示例代码为:

1
2
3
4
5
6
7
public static void templateDemo() {
String template = "Hi {name}, your code is {code}.";
Map<String, Object> params = new HashMap<String, Object>();
params.put("name", "老马");
params.put("code", 6789);
System.out.println(templateEngine(template, params));
}

输出为:

1
Hi 老马, your code is 6789.

完整代码在github上,地址为 https://github.com/swiftma/program-logic ,位于包shuo. laoma.regex.c89下。下一节,我们讨论和分析一些常见的正则表达式。

25.2 Java API

正则表达式相关的类位于包java.util.regex下,有两个主要的类,一个是Pattern,另一个是Matcher。Pattern表示正则表达式对象,它与要处理的具体字符串无关。Matcher表示一个匹配,它将正则表达式应用于一个具体字符串,通过它对字符串进行处理。

字符串类String也是一个重要的类,我们之前专门介绍过String,其中提到,它有一些方法,接受的参数不是普通的字符串,而是正则表达式。此外,正则表达式在Java中是需要先以字符串形式表示的。

下面,我们先来介绍如何表示正则表达式,然后探讨如何利用它实现一些常见的文本处理任务,包括切分、验证、查找和替换。

1.表示正则表达式

正则表达式由元字符和普通字符组成,字符’\‘是一个元字符,要在正则表达式中表示’\‘本身,需要使用它转义,即’\\‘。

在Java中,没有什么特殊的语法能直接表示正则表达式,需要用字符串表示,而在字符串中,’\‘也是一个元字符,为了在字符串中表示正则表达式的’\‘,就需要使用两个’\‘,即’\\‘,而要匹配’\‘本身,就需要4个’\‘,即’\\\\‘。比如,如下表达式:

1
<(\w+)>(.*)</\1>

对应的字符串表示就是:

1
"<(\\w+)>(.*)</\\1>"

一个简单规则是:**正则表达式中的任何一个’\‘,在字符串中,需要替换为两个’\‘**。

字符串表示的正则表达式可以被编译为一个Pattern对象,比如:

1
2
String regex = "<(\\w+)>(.*)</\\1>";
Pattern pattern = Pattern.compile(regex);

Pattern是正则表达式的面向对象表示,所谓编译,简单理解就是将字符串表示为了一个内部结构,这个结构是一个有穷自动机。关于有穷自动机的理论比较深入,我们就不探讨了。

编译有一定的成本,而且Pattern对象只与正则表达式有关,与要处理的具体文本无关,它可以安全地被多线程共享,所以,在使用同一个正则表达式处理多个文本时,应该尽量重用同一个Pattern对象,避免重复编译。

Pattern的compile方法接受一个额外参数,可以指定匹配模式:

1
public static Pattern compile(String regex, int flags)

上节,我们介绍过三种匹配模式:单行模式(点号模式)、多行模式和大小写无关模式,它们对应的常量分别为:Pattern.DOTALLPattern.MULTILINEPattern.CASE_INSENSI-TIVE,多个模式可以一起使用,通过’|’连起来即可,如下所示:

1
Pattern.compile(regex, Pattern.CASE_INSENSITIVE | Pattern.DOTALL)

还有一个模式Pattern.LITERAL,在此模式下,正则表达式字符串中的元字符将失去特殊含义,被看作普通字符。Pattern有一个静态方法:

1
public static String quote(String s)

quote()的目的是类似的,它将s中的字符都看作普通字符。我们在上节介绍过\Q\E, \Q\E之间的字符会被视为普通字符。quote()基本上就是在字符串s的前后加了\Q\E,比如,如果s为”\\d{6}“,则quote()的返回值就是”\\Q\\d{6}\\E“。

2.切分

文本处理的一个常见需求是根据分隔符切分字符串,比如在处理CSV文件时,按逗号分隔每个字段,这个需求听上去很容易满足,因为String类有如下方法:

1
public String[] split(String regex)

比如:

1
2
String str = "abc, def, hello";
String[] fields = str.split(", ");

不过,有一些重要的细节,我们需要注意。

split将参数regex看作正则表达式,而不是普通的字符,如果分隔符是元字符,比如. $|()[{^?*+\,就需要转义。比如按点号’.‘分隔,需要写为:

1
String[] fields = str.split("\\.");

如果分隔符是用户指定的,程序事先不知道,可以通过Pattern.quote()将其看作普通字符串。

既然是正则表达式,分隔符就不一定是一个字符,比如,可以将一个或多个空白字符或点号作为分隔符,如下所示:

1
2
String str = "abc   def        hello.\n    world";
String[] fields = str.split("[\\s.]+");

fields内容为:

1
[abc, def, hello, world]

需要说明的是,尾部的空白字符串不会包含在返回的结果数组中,但头部和中间的空白字符串会被包含在内,比如:

1
2
3
4
String str = ", abc, , def, , ";
String[] fields = str.split(", ");
System.out.println("field num: "+fields.length);
System.out.println(Arrays.toString(fields));

输出为:

1
2
field num: 4
[, abc, , def]

如果字符串中找不到匹配regex的分隔符,返回数组长度为1,元素为原字符串。

Pattern也有split方法,与String方法的定义类似:

1
public String[] split(CharSequence input)

与String方法的区别如下。
1)Pattern接受的参数是CharSequence,更为通用,我们知道String、StringBuilder、StringBuffer、CharBuffer等都实现了该接口。
2)如果regex长度大于1或包含元字符,String的split方法必须先将regex编译为Pattern对象,再调用Pattern的split方法,这时,为避免重复编译,应该优先采用Pattern的方法。
3)如果regex就是一个字符且不是元字符,String的split方法会采用更为简单高效的实现,所以,这时应该优先采用String的split方法。

3.验证

验证就是检验输入文本是否完整匹配预定义的正则表达式,经常用于检验用户的输入是否合法。String有如下方法:

1
public boolean matches(String regex)

比如:

1
2
3
String regex = "\\d{8}";
String str = "12345678";
System.out.println(str.matches(regex));

检查输入是否是8位数字,输出为true。

String的matches实际调用的是Pattern的如下方法:

1
public static boolean matches(String regex, CharSequence input)

这是一个静态方法,它的代码为:

1
2
3
4
5
public static boolean matches(String regex, CharSequence input) {
Pattern p = Pattern.compile(regex);
Matcher m = p.matcher(input);
return m.matches();
}

就是先调用compile编译regex为Pattern对象,再调用Pattern的matcher方法生成一个匹配对象Matcher, Matcher的matches方法返回是否完整匹配。

4.查找

查找就是在文本中寻找匹配正则表达式的子字符串,看个例子:

1
2
3
4
5
6
7
8
9
10
public static void find(){
String regex = "\\d{4}-\\d{2}-\\d{2}";
Pattern pattern = Pattern.compile(regex);
String str = "today is 2017-06-02, yesterday is 2017-06-01";
Matcher matcher = pattern.matcher(str);
while(matcher.find()){
System.out.println("find "+matcher.group()
+" position: "+matcher.start()+"-"+matcher.end());
}
}

代码寻找所有类似”2017-06-02”这种格式的日期,输出为:

1
2
find 2017-06-02 position: 9-19
find 2017-06-01 position: 34-44

Matcher的内部记录有一个位置,起始为0, find方法从这个位置查找匹配正则表达式的子字符串,找到后,返回true,并更新这个内部位置,匹配到的子字符串信息可以通过如下方法获取:

1
2
3
4
5
6
//匹配到的完整子字符串
public String group()
//子字符串在整个字符串中的起始位置
public int start()
//子字符串在整个字符串中的结束位置加1
public int end()

group()其实调用的是group(0),表示获取匹配的第0个分组的内容。我们在上节介绍过捕获分组的概念,分组0是一个特殊分组,表示匹配的整个子字符串。除了分组0, Matcher还有如下方法,获取分组的更多信息:

1
2
3
4
5
6
7
8
9
10
//分组个数
public int groupCount()
//分组编号为group的内容
public String group(int group)
//分组命名为name的内容
public String group(String name)
//分组编号为group的起始位置
public int start(int group)
//分组编号为group的结束位置加1
public int end(int group)

比如:

1
2
3
4
5
6
7
8
9
10
public static void findGroup() {
String regex = "(\\d{4})-(\\d{2})-(\\d{2})";
Pattern pattern = Pattern.compile(regex);
String str = "today is 2017-06-02, yesterday is 2017-06-01";
Matcher matcher = pattern.matcher(str);
while (matcher.find()) {
System.out.println("year:" + matcher.group(1)
+ ", month:" + matcher.group(2) + ", day:" + matcher.group(3));
}
}

输出为:

1
2
year:2017, month:06, day:02
year:2017, month:06, day:01

5.替换

查找到子字符串后,一个常见的后续操作是替换。String有多个替换方法:

1
2
3
4
public String replace(char oldChar, char newChar)
public String replace(CharSequence target, CharSequence replacement)
public String replaceAll(String regex, String replacement)
public String replaceFirst(String regex, String replacement)

第一个replace方法操作的是单个字符,第二个是CharSequence,它们都是将参数看作普通字符。而replaceAll和replaceFirst则将参数regex看作正则表达式,它们的区别是, replaceAll替换所有找到的子字符串,而replaceFirst则只替换第一个找到的。看个简单的例子,将字符串中的多个连续空白字符替换为一个:

1
2
3
String regex = "\\s+";
String str = "hello world good";
System.out.println(str.replaceAll(regex, " "));

输出为:

1
hello world good

在replaceAll和replaceFirst中,参数replacement也不是被看作普通的字符串,可以使用美元符号加数字的形式(比如$
1)引用捕获分组。我们看个例子:

1
2
3
String regex = "(\\d{4})-(\\d{2})-(\\d{2})";
String str = "today is 2017-06-02.";
System.out.println(str.replaceFirst(regex, "$1/$2/$3"));

输出为:

1
today is 2017/06/02.

这个例子将找到的日期字符串的格式进行了转换。所以,字符’$’在replacement中是元字符,如果需要替换为字符’$’本身,需要使用转义。看个例子:

1
2
3
String regex = "#";
String str = "#this is a test";
System.out.println(str.replaceAll(regex, "\\$"));

如果替换字符串是用户提供的,为避免元字符的干扰,可以使用Matcher的如下静态方法将其视为普通字符串:

1
public static String quoteReplacement(String s)

String的replaceAll和replaceFirst调用的其实是Pattern和Matcher中的方法。比如, replaceAll的代码为:

1
2
3
public String replaceAll(String regex, String replacement) {
return Pattern.compile(regex).matcher(this).replaceAll(replacement);
}

replaceAll和replaceFirst都定义在Matcher中,除了一次性的替换操作外,Matcher还定义了边查找、边替换的方法:

1
2
public Matcher appendReplacement(StringBuffer sb, String replacement)
public StringBuffer appendTail(StringBuffer sb)

这两个方法用于和find()一起使用,我们先看个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public static void replaceCat() {
Pattern p = Pattern.compile("cat");
Matcher m = p.matcher("one cat, two cat, three cat");
StringBuffer sb = new StringBuffer();
int foundNum = 0;
while(m.find()) {
m.appendReplacement(sb, "dog");
foundNum++;
if(foundNum == 2) {
break;
}
}
m.appendTail(sb);
System.out.println(sb.toString());
}

在这个例子中,我们将前两个”cat”替换为了”dog”,其他”cat”不变,输出为:

1
one dog, two dog, three cat

StringBuffer类型的变量sb存放最终的替换结果,Matcher内部除了有一个查找位置,还有一个append位置,初始为0,当找到一个匹配的子字符串后,appendReplacement()做了三件事情:
1)将append位置到当前匹配之前的子字符串append到sb中,在第一次操作中,为”one “,第二次为”, two “。
2)将替换字符串append到sb中。
3)更新append位置为当前匹配之后的位置。

appendTail将append位置之后所有的字符append到sb中。

至此,正则表达式相关的主要Java API就介绍完了。我们讨论了如何在Java中表示正则表达式,如何利用它实现文本的切分、验证、查找和替换,对于替换,下面我们演示一个简单的模板引擎。

第25章 正则表达式

前面章节,我们提到了正则表达式,它提升了文本处理的表达能力,本章就来讨论正则表达式,它是什么?有什么用?各种特殊字符都是什么含义?如何用Java借助正则表达式处理文本?都有哪些常用正则表达式?我们分为4小节进行介绍:25.1节先简要介绍正则表达式的语法;25.2节介绍相关的Java API;25.3节利用Java API实现一个简单的模板引擎;25.4节讨论和分析一些常用的正则表达式。

25.1 语法

正则表达式是一串字符,它描述了一个文本模式,利用它可以方便地处理文本,包括文本的查找、替换、验证、切分等。正则表达式中的字符有两类:一类是普通字符,就是匹配字符本身;另一类是元字符,这些字符有特殊含义,这些元字符及其特殊含义构成了正则表达式的语法。

正则表达式有一个比较长的历史,各种与文本处理有关的工具、编辑器和系统都支持正则表达式,大部分编程语言也都支持正则表达式。虽然都叫正则表达式,但由于历史原因,不同语言、系统和工具的语法不太一样,本书主要针对Java语言,其他语言可能有所差别。

下面,我们就来简要介绍正则表达式的语法,我们先分为以下部分分别介绍:

  • 单个字符;
  • 字符组;
  • 量词;
  • 分组;
  • 特殊边界匹配;
  • 环视边界匹配。

最后针对转义、匹配模式和各种语法进行总结。

1.单个字符

大部分的单个字符就是用字符本身表示的,比如字符’0’、’3’、’a’、’马’等,但有一些单个字符使用多个字符表示,这些字符都以斜杠’\‘开头,比如:
1)特殊字符,比如tab字符’\t‘、换行符’\n‘、回车符’\r‘等。
2)八进制表示的字符,以\0开头,后跟1~3位数字,比如\0141,对应的是ASCII编码为97的字符,即字符’a’。
3)十六进制表示的字符,以\x开头,后跟两位字符,比如\x6A,对应的是ASCII编码为106的字符,即字符’j’。
4)Unicode编号表示的字符,以\u开头,后跟4位字符,比如\u9A6C,表示的是中文字符’马’,这只能表示编号在0xFFFF以下的字符,如果超出0XFFFF,使用\x{...}形式,比如\x{1f48e}
5)斜杠\本身,斜杠\是一个元字符,如果要匹配它自身,使用两个斜杠表示,即’\\‘。
6)元字符本身,除了’\‘,正则表达式中还有很多元字符,比如*?+等,要匹配这些元字符自身,需要在前面加转义字符’\‘,比如’\.‘。

2.字符组

字符组有多种,包括任意字符、多个指定字符之一、字符区间、排除型字符组、预定义的字符组等,下面具体介绍。

点号字符’.’是一个元字符,默认模式下,它匹配除了换行符以外的任意字符,比如正则表达式:

1
a.f

既匹配字符串”abf“,也匹配”acf“。可以指定另外一种匹配模式,一般称为单行匹配模式或者点号匹配模式,在此模式下,’.‘匹配任意字符,包括换行符。可以有两种方式指定匹配模式:一种是在正则表达式中,以(?s)开头,s表示single line,即单行匹配模式。比如:

1
(?s)a.f

另外一种是在程序中指定,在Java中,对应的模式常量是Pattern.DOTALL,下节我们再介绍Java API。

在单个字符和任意字符之间,有一个字符组的概念,匹配组中的任意一个字符,用中括号[]表示,比如:

1
[abcd]

匹配a、b、c、d中的任意一个字符。

1
[0123456789]

匹配任意一个数字字符。

为方便表示连续的多个字符,字符组中可以使用连字符’-‘,比如:

1
2
[0-9]
[a-z]

可以有多个连续空间,可以有其他普通字符,比如:

1
[0-9a-zA-Z_]

在字符组中,’-‘是一个元字符,如果要匹配它自身,可以使用转义,即’-‘,或者把它放在字符组的最前面,比如:

1
[-0-9]

字符组支持排除的概念,在[后紧跟一个字符^,比如:

1
[^abcd]

表示匹配除了a, b, c, d以外的任意一个字符。

1
[^0-9]

表示匹配一个非数字字符。

排除不是不能匹配,而是匹配一个指定字符组以外的字符,要表达不能匹配的含义,需要使用后文介绍的环视语法。^只有在字符组的开头才是元字符,如果不在开头,就是普通字符,匹配它自身,比如:

1
[a^b]

就是匹配字符a, ^或b。

在字符组中,除了^-[ ]\外,其他在字符组外的元字符不再具备特殊含义,变成了普通字符,比如字符’.‘和’*‘, [.*]就是匹配’.‘或者’*‘本身。

有一些特殊的以\开头的字符,表示一些预定义的字符组,比如:

  • \d:d表示digit,匹配一个数字字符,等同于[0-9]
  • \w:w表示word,匹配一个单词字符,等同于[a-zA-Z_0-9]
  • \s:s表示space,匹配一个空白字符,等同于[ \t\n\x0B\f\r]

它们都有对应的排除型字符组,用大写表示,即:

  • \D:匹配一个非数字字符,即[^\d]
  • \W:匹配一个非单词字符,即[^\w]
  • \S:匹配一个非空白字符,即[^\s]

还有一类字符组,称为POSIX字符组,它们是POSIX标准定义的一些字符组,在Java中,这些字符组的形式是\p{...}。POSIX字符组比较多,我们就不介绍了。

3.量词

量词指的是指定出现次数的元字符,有三个常见的元字符:+*?
1)+:表示前面字符的一次或多次出现,比如正则表达式ab+c,既能匹配abc,也能匹配abbc,或abbbc。
2)*:表示前面字符的零次或多次出现,比如正则表达式ab*c,既能匹配abc,也能匹配ac,或abbbc。
3)?:表示前面字符可能出现,也可能不出现,比如正则表达式ab? c,既能匹配abc,也能匹配ac,但不能匹配abbc。

更为通用的表示出现次数的语法是{m,n},出现次数从m到n,包括m和n,如果n没有限制,可以省略,如果m和n一样,可以写为{m},比如:

  • ab{1,10}c:b可以出现1次到10次。
  • ab{3}c:b必须出现三次,即只能匹配abbbc
  • ab{1,}c:与ab+c一样。
  • ab{0,}c:与ab*c一样。
  • ab{0,1}c:与ab?c一样。

需要注意的是,语法必须是严格的{m,n}形式,逗号左右不能有空格。

?*+{是元字符,如果要匹配这些字符本身,需要使用’\‘转义,比如:

1
a\*b

匹配字符串”a*b“。这些量词出现在字符组中时,不是元字符,比如:

1
[? *+{]

就是匹配其中一个字符本身。

关于量词,它们的默认匹配是贪婪的,什么意思呢?看个例子,正则表达式是:

1
<a>.*</a>

如果要处理的字符串是:

1
<a>first</a><a>second</a>

目的是想得到两个匹配,一个匹配:

1
<a>first</a>

另一个匹配:

1
<a>second</a>

但默认情况下,得到的结果却只有一个匹配,匹配所有内容。

这是因为.*可以匹配第一个<a>和最后一个</a>之间的所有字符,只要能匹配,.*就尽量往后匹配,它是贪婪的。如果希望在碰到第一个匹配时就停止呢?应该使用懒惰量词,在量词的后面加一个符号’?‘,针对上例,将表达式改为:

1
<a>.*? </a>

就能得到期望的结果。所有量词都有对应的懒惰形式,比如:x??x*?x+?x{m,n}?等。

4.分组

表达式可以用括号()括起来,表示一个分组,比如a(bc)d, bc就是一个分组。分组可以嵌套,比如a(de(fg))。分组默认都有一个编号,按照括号的出现顺序,从1开始,从左到右依次递增,比如表达式:

1
a(bc)((de)(fg))

字符串abcdefg匹配这个表达式,第1个分组为bc,第2个为defg,第3个为de,第4个为fg。分组0是一个特殊分组,内容是整个匹配的字符串,这里是abcdefg。

分组匹配的子字符串可以在后续访问,好像被捕获了一样,所以默认分组称为捕获分组。关于如何在Java中访问和使用捕获分组,我们下节再介绍。

可以对分组使用量词,表示分组的出现次数,比如a(bc)+d,表示bc出现一次或多次。

中括号[]表示匹配其中的一个字符,括号()和元字符’|’一起,可以表示匹配其中的一个子表达式,比如:

1
(http|ftp|file)

匹配http或ftp或file。

需要注意区分|和[], |用于[]中不再有特殊含义,比如:

1
[a|b]

它的含义不是匹配a或b,而是a或|或b。

在正则表达式中,可以使用斜杠\加分组编号引用之前匹配的分组,这称为回溯引用,比如:

1
<(\w+)>(.*)</\1>

\1匹配之前的第一个分组(\w+),这个表达式可以匹配类似如下字符串:

1
<title>bc</title>

这里,第一个分组是”title”。

使用数字引用分组,可能容易出现混乱,可以对分组进行命名,通过名字引用之前的分组,对分组命名的语法是(? <name>X),引用分组的语法是\k<name>,比如,上面的例子可以写为:

1
<(?<tag>\w+)>(.*)</\k<tag>>

默认分组都称为捕获分组,即分组匹配的内容被捕获了,可以在后续被引用。实现捕获分组有一定的成本,为了提高性能,如果分组后续不需要被引用,可以改为非捕获分组,语法是(?:...),比如:

1
(?:abc|def)

5.特殊边界匹配

在正则表达式中,除了可以指定字符需满足什么条件,还可以指定字符的边界需满足什么条件,或者说匹配特定的边界,常用的表示特殊边界的元字符有^$\A\Z\z\b

默认情况下,^匹配整个字符串的开始,^abc表示整个字符串必须以abc开始。

需要注意的是^的含义,在字符组中它表示排除,但在字符组外,它匹配开始,比如表达式^[^abc],表示以一个不是a、b、c的字符开始。

默认情况下,$匹配整个字符串的结束,不过,如果整个字符串以换行符结束,$匹配的是换行符之前的边界,比如表达式

abc$
,表示整个表达式以abc结束,或者以abc\r\nabc\n结束。

以上^$的含义是默认模式下的,可以指定另外一种匹配模式:多行匹配模式,在此模式下,会以行为单位进行匹配,^匹配的是行开始,$匹配的是行结束,比如表达式是^abc$,字符串是”abc\nabc\r\n“,就会有两个匹配。

可以有两种方式指定匹配模式。一种是在正则表达式中,以(?m)开头,m表示multi-line,即多行匹配模式,上面的正则表达式可以写为:

1
(? m)^abc$

另外一种是在程序中指定,在Java中,对应的模式常量是Pattern.MULTILINE,下节我们再介绍Java API。

需要说明的是,多行模式和之前介绍的单行模式容易混淆,其实,它们之间没有关系。单行模式影响的是字符’.‘的匹配规则,使得’.‘可以匹配换行符多行模式影响的是^$的匹配规则,使得它们可以匹配行的开始和结束,两个模式可以一起使用

\A^类似,但不管什么模式,它匹配的总是整个字符串的开始边界

\Z\z$类似,但不管什么模式,它们匹配的总是整个字符串的结束边界。\Z\z的区别是:如果字符串以换行符结束,\Z$一样,匹配的是换行符之前的边界,而\z匹配的总是结束边界。在进行输入验证的时候,为了确保输入最后没有多余的换行符,可以使用\z进行匹配。

\b匹配的是单词边界,比如\bcat\b,匹配的是完整的单词cat,它不能匹配category。**\b匹配的不是一个具体的字符,而是一种边界,这种边界满足一个要求,即一边是单词字符,另一边不是单词字符**。在Java中,\b识别的单词字符除了\w,还包括中文字符。

边界匹配可能难以理解,我们解释下。边界匹配不同于字符匹配,可以认为,在一个字符串中,每个字符的两边都是边界,而上面介绍的这些特殊字符,匹配的都不是字符,而是特定的边界,看个例子,如图25-1所示。

epub_923038_145

图25-1 边界匹配示例

上面的字符串是”a cat\n”,我们用粗线显示出了每个字符两边的边界,并且显示出了每个边界与哪些边界元字符匹配。

6.环视边界匹配

对于边界匹配,除了使用上面介绍的边界元字符,还有一种更为通用的方式,那就是环视。环视的字面意思就是左右看看,需要左右符合一些条件,本质上,它也是匹配边界,对边界有一些要求,这个要求是针对左边或右边的字符串的。根据要求不同,分为4种环视:
1)肯定顺序环视,语法是(?=...),要求右边的字符串匹配指定的表达式。比如表达式abc(?=def), (?=def)在字符c右面,即匹配c右面的边界。对这个边界的要求是:它的右边有def,比如abcdef,如果没有,比如abcd,则不匹配。
2)否定顺序环视,语法是(?!...),要求右边的字符串不能匹配指定的表达式。比如表达式s(?!ing),匹配一般的s,但不匹配后面有ing的s。注意:避免与排除型字符组混淆,比如s[^ing], s[^ing]匹配的是两个字符,第一个是s,第二个是i、n、g以外的任意一个字符。
3)肯定逆序环视,语法是(?<=...),要求左边的字符串匹配指定的表达式。比如表达式(?<=\s)abc, (?<=\s)在字符a左边,即匹配a左边的边界。对这个边界的要求是:它的左边必须是空白字符。
4)否定逆序环视,语法是(?<!...),要求左边的字符串不能匹配指定的表达式。比如表达式(?<!\w)cat, (?<!\w)在字符c左边,即匹配c左边的边界。对这个边界的要求是:它的左边不能是单词字符。

可以看出,环视也使用括号(),不过,它不是分组,不占用分组编号。

这些环视结构也被称为断言,断言的对象是边界,边界不占用字符,没有宽度,所以也被称为零宽度断言

顺序环视也可以出现在左边,比如表达式:

1
(?=.*[A-Z])\w+

这个表达式是什么意思呢?\w+匹配多个单词字符,(? =.*[A-Z])匹配单词字符的左边界,这是一个肯定顺序环视。对这个边界的要求是,它右边的字符串匹配表达式:

1
.*[A-Z]

也就是说,它右边至少要有一个大写字母。

逆序环视也可以出现在右边,比如表达式:

1
[\w.]+(?<!\.)

[\w.]+匹配单词字符和字符’.’构成的字符串,比如”hello.ma“。(?<!\.)匹配字符串的右边界,这是一个逆序否定环视。对这个边界的要求是:它左边的字符不能是’.‘,也就是说,如果字符串以’.‘结尾,则匹配的字符串中不能包括这个’.‘。比如,如果字符串是”hello.ma.“,则匹配的子字符串是”hello.ma“。

环视匹配的是一个边界,里面的表达式是对这个边界左边或右边字符串的要求,对同一个边界,可以指定多个要求,即写多个环视,比如表达式:

1
(? =.*[A-Z])(? =.*[0-9])\w+

\w+的左边界有两个要求,(?=.*[A-Z])要求后面至少有一个大写字母,(?=.*[0-9])要求后面至少有一位数字。

7.转义与匹配模式

我们知道,字符’'表示转义,转义有两种。
1)把普通字符转义,使其具备特殊含义,比如’\t‘、’\n‘、’\d‘、’\w‘、’\b‘、’\A‘等,也就是说,这个转义把普通字符变为了元字符。
2)把元字符转义,使其变为普通字符,比如’\.‘、’\*‘、’\?‘、’\(‘、’\\‘等。

记住所有的元字符,并在需要的时候进行转义,这是比较困难的,有一个简单的办法,可以将所有元字符看作普通字符,就是在开始处加上\Q,在结束处加上\E,比如:

1
\Q(.*+)\E

\Q\E之间的所有字符都会被视为普通字符。

正则表达式用字符串表示,在Java中,字符’\‘也是字符串语法中的元字符,这使得正则表达式中的’\‘,在Java字符串表示中,要用两个’\‘,即’\\‘,而要匹配字符’\‘本身,在Java字符串表示中,要用4个’\‘,即’\\\`‘,关于这点,下节我们会进一步说明。

前面提到了两种匹配模式,还有一种常用的匹配模式,就是不区分大小写的模式,指定方式也有两种。一种是在正则表达式开头使用(?i), i为ignore,比如:

1
(?i)the

既可以匹配the,也可以匹配THE,还可以匹配The。匹配模式也可以在程序中指定,Java中对应的变量是Pattern.CASE_INSENSITIVE。需要说明的是,匹配模式间不是互斥的关系,它们可以一起使用,在正则表达式中,可以指定多个模式,比如(? smi)。

8.语法总结

下面,我们用表格的形式简要汇总下正则表达式的语法,如表25-1到表25-6所示。

表25-1 单个字符语法

epub_923038_146

表25-2 字符组语法

epub_923038_147

表25-3 量词语法

epub_923038_148

表25-4 分组语法

epub_923038_149

表25-5 边界和环视语法

epub_923038_150

表25-6 匹配模式和转义语法

epub_923038_151

24.5 自定义ClassLoader的应用:热部署

所谓热部署,就是在不重启应用的情况下,当类的定义即字节码文件修改后,能够替换该Class创建的对象,怎么做到这一点呢?我们利用MyClassLoader,看个简单的示例。

我们使用面向接口的编程,定义一个接口IHelloService:

1
2
3
public interface IHelloService {
public void sayHello();
}

实现类是shuo.laoma.dynamic.c87.HelloImpl, class文件放到MyClassLoader的加载目录中。

演示类是HotDeployDemo,它定义了以下静态变量:

1
2
3
4
private static final String CLASS_NAME = "shuo.laoma.dynamic.c87.HelloImpl";
private static final String FILE_NAME = "data/c87/"
+CLASS_NAME.replaceAll("\\.", "/")+".class";
private static volatile IHelloService helloService;

CLASS_NAME表示实现类名称,FILE_NAME是具体的class文件路径,helloService是IHelloService实例。

当CLASS_NAME代表的类字节码改变后,我们希望重新创建helloService,反映最新的代码,怎么做呢?先看用户端获取IHelloService的方法:

1
2
3
4
5
6
7
8
9
10
11
public static IHelloService getHelloService() {
if(helloService ! = null) {
return helloService;
}
synchronized (HotDeployDemo.class) {
if(helloService == null) {
helloService = createHelloService();
}
return helloService;
}
}

这是一个单例模式,createHelloService()的代码为:

1
2
3
4
5
6
7
8
9
10
11
12
private static IHelloService createHelloService() {
try {
MyClassLoader cl = new MyClassLoader();
Class<? > cls = cl.loadClass(CLASS_NAME);
if(cls ! = null) {
return (IHelloService) cls.newInstance();
}
} catch (Exception e) {
e.printStackTrace();
}
return null;
}

它使用MyClassLoader加载类,并利用反射创建实例,它假定实现类有一个public无参构造方法。

在调用IHelloService的方法时,客户端总是先通过getHelloService获取实例对象,我们模拟一个客户端线程,它不停地获取IHelloService对象,并调用其方法,然后睡眠1秒钟,其代码为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public static void client() {
Thread t = new Thread() {
@Override
public void run() {
try {
while (true) {
IHelloService helloService = getHelloService();
helloService.sayHello();
Thread.sleep(1000);
}
} catch (InterruptedException e) {
}
}
};
t.start();
}

怎么知道类的class文件发生了变化,并重新创建helloService对象呢?我们使用一个单独的线程模拟这一过程,代码为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public static void monitor() {
Thread t = new Thread() {
private long lastModified = new File(FILE_NAME).lastModified();
@Override
public void run() {
try {
while(true) {
Thread.sleep(100);
long now = new File(FILE_NAME).lastModified();
if(now ! = lastModified) {
lastModified = now;
reloadHelloService();
}
}
} catch (InterruptedException e) {
}
}
};
t.start();
}

我们使用文件的最后修改时间来跟踪文件是否发生了变化,当文件修改后,调用reloadHelloService()来重新加载,其代码为:

1
2
3
public static void reloadHelloService() {
helloService = createHelloService();
}

就是利用MyClassLoader重新创建HelloService,创建后,赋值给helloService,这样,下次getHelloService()获取到的就是最新的了。

在主程序中启动client和monitor线程,代码为:

1
2
3
4
public static void main(String[] args) {
monitor();
client();
}

在运行过程中,替换HelloImpl.class,可以看到行为会变化,为便于演示,我们在data/c87/shuo/laoma/dynamic/c87/目录下准备了两个不同的实现类:HelloImpl_origin.class和HelloImpl_revised. class,在运行过程中替换,会看到输出不一样,如图24-1所示。

epub_923038_143

图24-1 动态替换实现类示例

使用cp命令修改HelloImpl.class,如果其内容与HelloImpl_origin.class一样,输出为”hello”;如果与HelloImpl_revised.class一样,输出为”hello revised”。

完整的代码和数据在github上,地址为https://github.com/swiftma/program-logic ,位于包shuo.laoma.dynamic.c87下。

本章介绍了Java中的类加载机制,包括Java加载类的基本过程,类ClassLoader的用法,以及如何创建自定义的ClassLoader,探讨了两个简单应用示例,一个通过动态加载实现了可配置的策略,另一个通过自定义ClassLoader实现了热部署。

需要说明的是,Java 9引入了模块的概念。在模块化系统中,类加载的过程有一些变化,扩展类的目录被删除掉了,原来的扩展类加载器没有了,增加了一个平台类加载器(Platform Class Loader),角色类似于扩展类加载器,它分担了一部分启动类加载器的职责,另外,加载的顺序也有一些变化,限于篇幅,我们就不探讨了。

从第21章到本章,我们探讨了Java中的多个动态特性,包括反射、注解、动态代理和类加载器,作为应用程序员,大部分用得都比较少,用得较多的就是使用框架和库提供的各种注解了,但这些特性大量应用于各种系统程序、框架和库中,理解这些特性有助于我们更好地理解它们,也可以在需要的时候自己实现动态、通用、灵活的功能。

在注解一章,我们提到,注解是一种声明式编程风格,它提高了Java语言的表达能力,日常编程中一种常见的需求是文本处理,在计算机科学中,有一种技术大大提高了文本处理的表达能力,那就是正则表达式,大部分编程语言都有对它的支持,它有什么强大功能呢?让我们下一章探讨。