Java 函数式编程解析

前段日子看了看泛型,现在再来看两手 Java 8 引入的函数式编程。

Java 的函数式编程是通过 interface 来实现的,对于 Java 这样的语言通过使用 SAM 接口来实现函数式主要是为了简化 Java FP 的入门门槛,Java 并没有采用引入新关键字的方式(例如:C# 引入了 delegation 关键字以及复杂的委托机制),这也使得 Java 当中 函数 不是一等公民,无法脱离 class 的怀抱,这也使得 FP 带来的便捷性受到了一定的损失,(Java 的函数式与真正的函数式语言相比,写起来复杂了很多)通过 SAM 实现函数式是在 Java 环境下的妥协,本文不会对于这一点做过深的讨论。

Lambda

函数式总离不开 Lambda 表达式,Lambda 表达式可以替代匿名内部类,以及在标注了函数式接口 @FunctionalInterface 的地方使用。

1
2
3
4
5
6
7
8
()->System.out.println("HelloWorld");
event->System.out.println("button clicked");
(x,y)->x+y;
(Long x, Long y)->x+y;
()->{
System.out.println("Hello");
System.out.println("World");
}

可以看到 Lambda 表达式一般是以 ()->{} 形式,前面填参数,后面填函数语句。Lambda 传进来的参数必须是 final 的或者是既定是不可变的,所以 Lambda 表达式不能改变传进来的参数的值,这也是符合函数式的设计原则的——函数不能有副作用

前面也提到了 Lambda 可以用在所有传入被 @FunctionalInterface 注解的接口的地方。例如以下形式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
//所定义的函数式接口
@FunctionalInterface
public interface Consumer<T> {
void accept(T t);
}

//在外部写一个函数,函数的参数为此接口。
public <T> void accept(final T t, final Consumer<T> consumer) {
consumer.accept(t)
}

//调用这个函数时直接可以传入 Lambda 表达式
public void test() {
accept("HelloWorld", System.out::println);
}

结果输出:

1
HelloWorld

上面代码中的 Consumer 接口其实本体位于 JDK 的 java.util.function 包中,JDK 提供了一些基础的函数式接口,下面介绍几个基本的接口。

接口 参数 返回类型
Predicate<T> T boolean
Consumer<T> T void
Function<T,R> T R
Supplier<T> None T
UnaryOperator<T> T T

很多情况下使用这些接口其实就够用了,这里也看到了使用 <> 来进行类型推断,这也是之前 Lambda 例子当中为什么可以不用写类型的原因。

stream

Java 8 中更激动人心的特性就是 stream 了,它能够简化 collection 以及 map 的操作,下面给出个 stream 的实例。

1
2
3
long count  = allArtists.stream()
.filter(artist -> artist.isFrom("London"))
.count();

上述例子计算了所有来自伦敦的艺术家,这里有两个重要的概念引入,一是惰性求值,二十及早求值, filter 当中的 Lambda 表达式时惰性的,只有在调用了及早求值(这里是调用 count() )时,才会作出计算,例如下面这个例子:

1
2
3
4
5
allArtists.stream()
.filter(artist -> {
System.out.println("HelloWorld");
artist.isFrom("London");
})

这里并不会输出 HelloWorld,这两个概念在函数式编程语言当中以及习以为常了。 PS:上述的 Lambda 表达式是具有副作用的,它向控制台输出了信息。

stream 流中封装了很多接口,大部分都是高阶函数(接收或返回一个函数的函数)如 filter, map 等,stream 也对自动装箱做了优化,Integer 对象比 int 基本类型占用了更多的开销,但 stream 仅对整型,长整型和双精度浮点数进行了优化 mapToLong, LongStream

函数重载

调用最具体的那个函数,包括函数前面,以及子类父类之间的关系,下面的例子会输出 IntegerBiFunction

1
2
3
4
5
6
7
8
9
overloadedMethod((x,y)->x+y);
//两个重载方法
private void overloadedMethod(BinaryOperator<Integer>){
System.out.println("BinaryOperator");
}

private void overloadedMethod(IntegerBiFunction lambda) {
System.out.println("IntegerBiFunction");
}

但是下面的例子就不能通过编译,因为 IntPredicate 和 Predicate 在类型上都是匹配的,而上面这个例子之所以能编译通过是因为 IntegerBiFunction 是 BinaryOperator 的子类。

1
2
3
4
5
6
7
8
9
overloadedMethod((x,y)->x+y);

private void overloadedMethod(Predicate<Integer> predicate) {
System.out.println("Predicate");
}

private void overloadedMethod(IntPredicate predicate) {
System.out.println("IntPredicate");
}

默认方法

Java 8 为了向后兼容,所以在接口中可以定义 default 关键字,即默认方法,默认方法有一套玄学的重载机制…..

  • 类胜于接口。如果在继承链中有方法或抽象方法的声明,那么就可以忽略接口中定义的方法。
  • 子类胜于父类。如果一个接口继承了另一个接口,且两个接口都定义了一个默认方法,那么子类中定义的方法胜出。
  • 没有规则三。如果上面两条都不使用,子类要么需要实现该方法,要么将该方法声明为抽象方法。

方法引用

第一个例子已经使用了方法引用 System.out::println, 在所有可以写成 x->foo(x) 的地方都可以使用 A::foo 替换,这样也使代码更加简洁,对于对象或者是类型名都可以使用。其实这个东西也玄,有序列化和方法引用这个坑

数据分块

将一个 list 通过某些条件分成两个 list, 大概就是这么做的。

1
2
3
public Map<Boolean, List<Artist>> bandsAndSolo(Stream<Artist> artists) {
return artists.collect(Collectors.partitioningBy(Artist::isSolo));
}

partitioninBy 接收一个 Predicate 然后根据每个值传入后输出的布尔值进行二分类。

最后

Java 函数式核心库主要的化还有 stream 流的并行还没有讲了,以后用到了的时候再写篇吧,以及 Objects 和 Optional 类以后也讲讲。