Java函数式编程(Lambda表达式、方法引用)详解

  1. 1. 新方式与旧方式的对比
  2. 2. Lambda表达式
  3. 3. 方法引用
    1. 3.1 Runnable
    2. 3.2 未绑定方法引用
    3. 3.3 构造器方法引用
  4. 4. 函数式接口
    1. 4.1 默认目标接口
    2. 4.2 带有更多参数的函数式接口
  5. 5. 高阶函数
  6. 6. 函数组合

本文介绍了 Java8 中的函数式编程方式,结合代码示例详细地讲解了什么是 Lambda 表达式与方法引用。

1. 新方式与旧方式的对比

通常情况下,方法会根据所传递的数据产生不同的结果。如果想让一个方法在每次调用时都有不同的表现呢?如果将代码传递给方法,就可以控制其行为。

以前的做法是,创建一个对象,让它的一个方法包含所需行为,然后将这个对象传递给我们想控制的方法。下面的示例演示了这一点,然后增加了 Java 8 的实现方式:方法引用和 Lambda 表达式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
package funcprog;

interface Strategy {
String approach(String msg);
}

class DefaultStrategy implements Strategy {
@Override
public String approach(String msg) {
return msg.toLowerCase() + "?";
}
}

class Unrelated {
static String twice(String msg) {
return msg + " " + msg;
}
}

public class Strategize {
Strategy strategy;
String msg;

Strategize(String msg) {
strategy = new DefaultStrategy();
this.msg = msg;
}

void f() {
System.out.println(strategy.approach(msg));
}

void changeStrategy(Strategy strategy) {
this.strategy = strategy;
}

public static void main(String[] args) {
Strategize s = new Strategize("Hello world");
s.f(); // hello world?

Strategy[] strategies = {
new Strategy() {
@Override
public String approach(String msg) {
return msg.toUpperCase() + "!";
}
}, // 匿名内部类
msg -> msg.substring(0, 5), // Lambda表达式
Unrelated::twice // 方法引用
};

for (Strategy newStrategy: strategies) {
s.changeStrategy(newStrategy);
s.f();
}

/*
* HELLO WORLD!
* Hello
* Hello world Hello world
*/
}
}

Strategy 提供了接口,功能是通过其中唯一的 approach() 方法来承载的,通过创建不同的 Strategy 对象,我们可以创建不同的行为。

传统上,我们通过定义一个实现了 Strategy 接口的类来完成这种行为,比如 DefaultStrategy。更简洁、自然的方式是创建一个匿名内部类,不过这样仍然会存在一定数量的重复代码,而且我们总是要花点功夫才能明白这里是在使用匿名内部类。

Java 8 的 Lambda 表达式突出的特点是用箭头 -> 将参数和函数体分隔开来,箭头右边是从 Lambda 返回的表达式,这和类定义以及匿名内部类实现了同样的效果,但是代码要少得多。

Java 8 的方法引用是用 ::,左边是类名或对象名,右边是方法名,但是没有参数列表。

2. Lambda表达式

Lambda 表达式是使用尽可能少的语法编写的函数定义。Lambda 表达式产生的是函数,而不是类,在 Java 虚拟机(JVM)上,一切都是类,所以幕后会有各种各样的操作让 Lambda 看起来像函数。

任何 Lambda 表达式的基本语法如下:

  1. 参数;
  2. 之后跟一个 ->,可以读作“产生”;
  3. -> 后面跟一个方法体。

需要注意以下几个方面:

  • 如果只有一个参数,可以只写这个参数,不写括号,也可以使用括号,尽管这种方式更不常见。
  • 如果有多个参数,将它们放在使用括号包裹起来的参数列表内。
  • 如果没有参数,必须使用括号来指示空的参数列表。
  • 如果方法体只有一行,那么方法体中表达式的结果会自动成为 Lambda 表达式的返回值,不能使用 return
  • 如果 Lambda 表达式需要多行代码,则必须将这些代玛行放到 {} 中,这种情况下需要使用 return 从 Lambda 表达式生成一个值。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
package funcprog;

interface Description {
String f();
}

interface Body {
String f(String str);
}

interface Multi {
String f(String str, int x);
}

public class LambdaExpressions {
static Description d = () -> "Hello World!";
static Body b1 = s -> "Hello " + s;
static Body b2 = (s) -> "Hello " + s;
static Multi m = (s, x) -> {
System.out.println("Multi");
return "Hello " + s + " " + x;
};

public static void main(String[] args) {
System.out.println(d.f());
System.out.println(b1.f("AsanoSaki"));
System.out.println(b2.f("AsanoSaki"));
System.out.println(m.f("AsanoSaki", 666));

/*
* Hello World!
* Hello AsanoSaki
* Hello AsanoSaki
* Multi
* Hello AsanoSaki 666
*/
}
}

递归意味着一个函数调用了自身。在 Java 中也可以编写递归的 Lambda 表达式,但是要注意这个 Lambda 表达式必须被赋值给一个静态变量或一个实例变星,否则会出现编译错误:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
package funcprog;

interface Factorial {
int f(int n);
}

public class RecursiveFactorial {
static Factorial fact; // 需要将Lambda表达式赋值给一个静态变量

public static void main(String[] args) {
fact = n -> n == 0 ? 1 : n * fact.f(n - 1);

for (int i = 0; i < 5; i++)
System.out.print(fact.f(i) + " "); // 1 1 2 6 24
}
}

请注意,不能在定义的时候像这样来初始化 fact

1
static Factorial fact = n -> n == 0 ? 1 : n * fact.f(n - 1);

尽管这样的期望非常合理,但是对于 Java 编译器而言处理起来太复杂了,所以会产生编译错误。

现在我们再用递归的 Lambda 表达式实现斐波那契数列,这次使用实例变量,用构造器来初始化:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
package funcprog;

interface Fibonacci {
int f(int n);
}

public class RecursiveFibonacci {
Fibonacci fib;

RecursiveFibonacci() { // 构造器内初始化Fibonacci
fib = n -> n == 0 ? 0 : n == 1 ? 1 : fib.f(n - 2) + fib.f(n - 1);
}

public static void main(String[] args) {
RecursiveFibonacci rf = new RecursiveFibonacci();
for (int i = 0; i < 10; i++)
System.out.print(rf.fib.f(i) + " "); // 0 1 1 2 3 5 8 13 21 34
}
}

3. 方法引用

Java 8 方法引用指向的是方法,没有之前 Java 版本的历史包袱,方法引用是用类名或对象名,后面跟 ::,然后跟方法名:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
package funcprog;

interface Callable {
void call(String s);
}

class Print { // 非静态类
void print(String s) { // 签名和call()一致
System.out.println(s);
}
}

public class MethodReferences {
static void hello(String s) { // 静态方法引用
System.out.println("Hello " + s);
}

static class GoodMorning {
void goodMorning(String s) { // 静态内部类的非静态方法
System.out.println("Good morning " + s);
}
}

static class GoodEvening {
static void goodEvening(String s) { // 静态内部类的静态方法
System.out.println("Good evening " + s);
}
}

public static void main(String[] args) {
Print p = new Print();
Callable c = p::print;
c.call("AsanoSaki");

c = MethodReferences::hello;
c.call("AsanoSaki");

c = new GoodMorning()::goodMorning;
c.call("AsanoSaki");

c = GoodEvening::goodEvening;
c.call("AsanoSaki");

/*
* AsanoSaki
* Hello AsanoSaki
* Good morning AsanoSaki
* Good evening AsanoSaki
*/
}
}

3.1 Runnable

java.lang 包中的 Runnable 接口也遵从特殊的单方法接口格式,其 run() 方法没有参数,也没有返回值,所以我们可以将 Lambda 表达式或方法引用用作 Runnable

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
package funcprog;

class RunnableReference {
static void f() {
System.out.println("RunnableReference::f()");
}
}

public class RunnableMethodReference {
public static void main(String[] args) {
// Thread对象接受一个Runnable作为其构造器参数
new Thread(new Runnable() { // 匿名内部类
@Override
public void run() {
System.out.println("Anonymous");
}
}).start(); // start()方法会调用run()

new Thread( // Lambda表达式
() -> System.out.println("Lambda")
).start();

new Thread(RunnableReference::f).start(); // 方法引用

/*
* Anonymous
* Lambda
* RunnableReference::f()
*/
}
}

3.2 未绑定方法引用

未绑定方法引用(unbound method reference)指的是尚未关联到某个对象的普通(非静态)方法,对于未绑定引用,必须先提供对象,然后才能使用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
package funcprog;

class A {
String f() { return "A::f()"; }
}

interface GetStringUnbound {
String get();
}

interface GetStringBoundA {
String get(A a);
}

public class UnboundMethodReference {
public static void main(String[] args) {
GetStringBoundA g = A::f;
A a = new A();

System.out.println(a.f()); // A::f()
System.out.println(g.get(a)); // A::f()
}
}

如果我们按以下方式将 A::f 赋值给 GetStringUnbound 编译器会报错,即使 get() 的签名和 f() 相同。问题在于,这里事实上还涉及另一个(隐藏的)参数:我们的老朋友 this。如果没有一个可供附着的 A 对象,就无法调用 f()。因此,A::f 代表的是一个未绑定方法引用,因为它没有绑定到某个对象。

为解决这个冋题,我们需要一个 A 对象,所以我们的接口事实上还需要一个额外的参数,如 GetStringBoundA 中所示,如果将 A::f 赋值给一个 GetStringBoundA,Java 则会幵心地接受。在未绑定引用的情况下,函数式方法(接口中的单一方法)的签名与方法引用的签名不再完全匹配,这样做有一个很好的理由,那就是我们需要一个对象,让方法在其上调用。

g.get(a) 中我们接受了未绑定引用,然后以 A 为参数在其上调用了 get(),最终以某种方式调用了 a.f()。Java 知道它必须接受第一个参数,事实上就是 this,并在它的上面调用该方法。

如果方法有更多参数,只要遵循第一个参数取的是 this 这种模式即可,即对于本例来说第一个参数为 A 即可。

3.3 构造器方法引用

我们也可以捕获对某个构造器的引用,之后通过该引用来调用那个构造器:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
package funcprog;

class Cat {
String name;

Cat() { name = "Kitty"; } // 无参构造器

Cat(String name) { this.name = name; } // 有参构造器
}

interface MakeCatNoArgs {
Cat makeCat();
}

interface MakeCatWithArgs {
Cat makeCat(String name);
}

public class ConstructorReference {
public static void main(String[] args) {
MakeCatNoArgs m1 = Cat::new; // 所有构造器名字都是new,编译器可以从接口来推断使用哪个构造器
MakeCatWithArgs m2 = Cat::new;

Cat c1 = m1.makeCat(); // 调用此处的函数式接口方法makeCat()意味着调用构造器Cat::new
Cat c2 = m2.makeCat("Lucy");
}
}

4. 函数式接口

方法引用和 Lambda 表达式都必须赋值,而这些赋值都需要类型信息,让编译器确保类型的正确性,尤其是 Lambda 表达式,又引入了新的要求,考虑如下代码:

1
x -> x.toString()

我们看到返回类型必须是 String,但是 x 是什么类型呢?因为 Lambda 表达式包含了某种形式的类型推断(编译器推断出类型的某些信息,而不需要程序员显式指定),所以编译器必须能够以某种方式推断出 x 的类型。

下面是第二个示例:

1
(x, y) -> x + y

现在 xy 可以是支持 + 操作符的任何类型,包括两种不同的数值类型,或者是一个 String 和某个能够自动转换为 String 的其他类型。

Java 8 引入了包含一组接口的 java.util.function,这些接口是 Lambda 表达式和方法引用的目标类型,每个接口都只包含一个抽象方法,叫作函数式方法。当编写接口时,这种函数式方法模式可以使用 @FunctionalInterface 注解来强制实施:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
package funcprog;

@FunctionalInterface
interface FunctionalPrint {
void print(String s);
}

@FunctionalInterface
interface NotFunctional {
void f1();
void f2();
} // 编译器会报错

public class FunctionalAnnotation {
public void hello(String s) {
System.out.println("Hello " + s);
}

public static void main(String[] args) {
FunctionalAnnotation f = new FunctionalAnnotation();
FunctionalPrint fp = f::hello;
fp.print("AsanoSaki"); // Hello AsanoSaki

fp = s -> System.out.println("Hi " + s);
fp.print("AsanoSaki"); // Hi AsanoSaki
}
}

@FunctionalInterface 注解是可选的,Java 会将 main() 中的 FunctionalPrint 看作函数式接口。在 NotFunctional 接口的定义中我们可以看到 @FunctionalInterface 的作用:如果接口中的方法多于一个,则会产生一条编译错误信息。

现在我们仔细看一下 fp 的定义中发生了什么,FunctionalPrint 定义了接口,然而被赋值给它们的只是方法 hello(),而不是类,它甚至不是实现了这里定义的某个接口的类中的方法。这是 Java 8 增加的一个小魔法:如果我们将一个方法引用或 Lambda 表达式赋值给某个函数式接口(而且类型可以匹配),那么 Java 会调整这个赋值,使其匹配目标接口。而在底层,Java 编译器会创建一个实现了目标接口的类的实例,并将我们的方法引用或 Lambda 表达式包裹在其中。

使用了 @FunctionalInterface 注解的接口也叫作单一抽象方法(Single Abstract Method,SAM)类型。

4.1 默认目标接口

java.util.function 旨在创建一套足够完备的目标接口,这样一般情况下我们就不需要定义自己的接口了。这一套接口的命名遵循一定的规律,一般来说通过名字就可以了解特定的接口是做什么的,部分接口示例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
package funcprog;

import java.util.function.BiConsumer;
import java.util.function.DoubleFunction;
import java.util.function.Function;
import java.util.function.IntToDoubleFunction;

public class FunctionVariants {
static Function<Integer, String> f = x -> "Function " + x;
static DoubleFunction<String> df = x -> "DoubleFunction " + x;
static IntToDoubleFunction itdf = x -> x / 2.0;
static BiConsumer<Integer, String> bc = (x, s) -> System.out.println("BiConsumer " + x + " " + s);

public static void main(String[] args) {
System.out.println(f.apply(6)); // Function 6
System.out.println(df.apply(6)); // DoubleFunction 6.0
System.out.println(itdf.applyAsDouble(6)); // 3.0
bc.accept(6, "AsanoSaki"); // BiConsumer 6 AsanoSaki
}
}

4.2 带有更多参数的函数式接口

java.util.function 中的接口毕竟是有限的,如果我们需要有3个参数的函数接口呢?因为那些接口相当直观,所以看一下 Java 库的源代码,然后编写我们自己的接口也很容易:

1
2
3
4
5
6
7
8
9
10
11
12
13
package funcprog;

@FunctionalInterface
interface TriFunction<T, U, V, R> { // R为泛型返回类型
R apply(T t, U u, V v);
}

public class TriFunctionTest {
public static void main(String[] args) {
TriFunction<Integer, Long, Double, Double> tf = (i, j, k) -> i + j + k;
System.out.println(tf.apply(1, 2L, 3D)); // 6.0
}
}

5. 高阶函数

高阶函数是一个能接受函数作为参数或能把函数当返回值的函数,有了 Lambda 表达式,在方法中创建并返回一个函数简直不费吹灰之力,要接受并使用函数,方法必须在其参数列表中正确地描述函数类型:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
package funcprog;

import java.util.function.Function;

public class ProduceFunction {
static Function<String, String> produce() { // 函数作为返回值
return String::toLowerCase; // 或者用Lambda表达式s -> s.toLowerCase()
}

static String consume(Function<Integer, String> toStr, int x) { // 函数作为参数
return toStr.apply(x);
}

public static void main(String[] args) {
Function<String, String> toLower = produce();
System.out.println(toLower.apply("Hello World")); // hello world

System.out.println(consume(x -> "To String " + x, 6)); // To String 6
}
}

6. 函数组合

函数组合是指将多个函数结合使用,以创建新的函数,这通常被认为是函数式编程的一部分。java.util.function 中的一些接口也包含了支持函数组合的方法,我们以 andThen()compose() 方法为例:

  • andThen():先执行原始操作,再执行方法参数中的操作。
  • compose():先执行方法参数中的操作,再执行原始操作。
1
2
3
4
5
6
7
8
9
10
11
12
13
package funcprog;

import java.util.function.Function;

public class FunctionComposition { // hello world
static Function<String, String> f1 = s -> s.substring(0, 5),
f2 = s -> new StringBuilder(s).reverse().toString();

public static void main(String[] args) {
System.out.println(f1.andThen(f2).apply("Hello World")); // olleH
System.out.println(f1.compose(f2).apply("Hello World")); // dlroW
}
}