2.3. 函数式编程#

函数式编程(Functional Programming)是一种编程范式,因其更适合做并行计算,近年来开始受到大数据开发者的广泛关注。Python、JavaScript 等语言对函数式编程的支持都不错;Scala 更是以函数式编程的优势在大数据领域“攻城略地”;即使是 Java,也为了适应函数式编程,加强对函数式编程的支持。未来的程序员或多或少都要了解一些函数式编程思想。这里抛开一些数学推理等各类复杂的概念,仅从 Flink 开发的角度带领读者熟悉函数式编程。

函数式编程思想简介#

在介绍函数式编程前,我们可以先回顾传统的编程范式如何解决一个数学问题。假设我们想求解一个数学表达式:

addResult = x + y
result = addResult * z

在这个例子中,我们要先求解中间结果,将其存储到中间变量,再进一步求得最终结果。这仅仅是一个简单的例子,在更多的编程实践中,程序员必须告诉计算机每一步执行什么命令、需要声明哪些中间变量等。因为计算机无法理解复杂的概念,只能听从程序员的指挥。

中学时代,我们的老师在数学课上曾花费大量时间讲解函数,函数指对于自变量的映射。函数式编程的思想正是基于数学中对函数的定义。其基本思想是,在使用计算机求解问题时,我们可以把整个计算过程定义为不同的函数。比如,将这个问题转化为:

result = multiply(add(x, y), z)

我们再对其做进一步的转换:

result = add(x, y).multiply(z)

传统思路中要创建中间变量,要分步执行,而函数式编程的形式与数学表达式形式更为相似。人们普遍认为,这种函数式的描述更接近人类自然语言。

如果要实现这样一个函数式程序,主要需要如下两步。

  1. 实现单个函数,将零到多个输入转换成零到多个输出。比如 add() 这种带有映射关系的函数,它将两个输入转化为一个输出。

  2. 将多个函数连接起来,实现所需业务逻辑。比如,将 add()multiply() 连接到一起。

接下来我们通过 Java 代码来展示如何实践函数式编程思想。

Lambda 表达式的内部结构#

数理逻辑领域有一个名为 λ 演算的形式系统,主要研究如何使用函数来表达计算。一些编程语言将这个概念应用到自己的平台上,期望能实现函数式编程,取名为 Lambda 表达式(λ 的英文拼写为 Lambda)。

我们先看一下 Java 的 Lambda 表达式的语法规则。

(parameters) -> {
  body 
}

Lambda 表达式主要包括一个箭头符号 ->,其两边连接着输入参数和函数体。我们再看看代码清单 2-5 中的几个 Java Lambda 表达式。

// 1. 无参数,返回值为 5  
() -> 5  

// 2. 接收 1 个 int 类型参数,将其乘以 2,返回一个 int 类型值
x -> 2 * x

// 3. 接收 2 个 int 类型参数,返回它们的差
(x, y) -> x - y  

// 4. 接收 2 个 int 类型参数,返回它们的和
(int x, int y) -> x + y

// 5. 接收 1 个 String 类型参数,将其输出到控制台,不返回任何值
(String s) -> {System.out.print(s); }

// 6. 参数为圆半径,返回圆面积,返回值为 double 类型
(double r) -> {
    double pi = 3.1415;
    return r * r * pi;
}

代码清单 2-5 Java Lambda 表达式

可以看到,这几个例子都有一个 ->,表示这是一个函数式的映射,相对比较灵活的是左侧的输入参数和右侧的函数体。图 2.2 所示为 Java Lambda 表达式的拆解,这很符合数学中对一个函数做映射的思维方式。

../_images/lambda1.png

图 2.2 Java Lambda 表达式拆解#

函数式接口#

通过前文的几个例子,我们大概知道 Lambda 表达式的内部结构了,那么 Lambda 表达式到底是什么类型呢?在 Java 中,Lambda 表达式是有类型的,它是一种接口。确切地说,Lambda 表达式实现了一个函数式接口(Functional Interface),或者说,前文提到的一些 Lambda 表达式都是函数式接口的具体实现。

函数式接口是一种接口,并且它只有一个虚函数。因为这种接口只有一个虚函数,因此其对应英文为 Single Abstract Method(SAM)。SAM 表示这个接口对外只提供这一个函数的功能。如果我们想自己设计一个函数式接口,我们应该给这个接口添加 @FunctionalInterface 注解。编译器会根据这个注解确保该接口是函数式接口,当我们尝试往该接口中添加超过一个虚函数时,编译器会报错。在代码清单 2-6 中,我们设计一个加法的函数式接口 AddInterface,然后实现这个接口。

@FunctionalInterface
interface AddInterface<T> {
    T add(T a, T b);
}

public class FunctionalInterfaceExample {

    public static void main(String[] args ) {

        AddInterface<Integer> addInt = (Integer a, Integer b) -> a + b;
        AddInterface<Double> addDouble = (Double a, Double b) -> a + b;

        int intResult;
        double doubleResult;

        intResult = addInt.add(1, 2);
        doubleResult = addDouble.add(1.1d, 2.2d);
    }
}

代码清单 2-6 一个能够实现加法功能的函数式接口

Lambda 表达式实际上是在实现函数式接口中的虚函数,Lambda 表达式的输入类型和返回类型要与虚函数定义的类型相匹配。

假如没有 Lambda 表达式,我们仍然可以实现这个函数式接口,只不过代码比较“臃肿”。首先,我们需要声明一个类来实现这个接口,可以是下面的类。

public static class MyAdd implements AddInterface<Double> {
    @Override
    public Double add(Double a, Double b) {
            return a + b;
    }
}

然后,在业务逻辑中这样调用:doubleResult = new MyAdd().add(1.1d, 2.2d);。或者是使用匿名类,省去 MyAdd 这个名字,直接实现 AddInterface 并调用:

doubleResult = new AddInterface<Double>(){
    @Override
    public Double add(Double a, Double b) {
        return a + b;
    }
}.add(1d, 2d);

声明类并实现接口和使用匿名类这两种方法是 Lambda 表达式出现之前,Java 开发者经常使用的两种方法。实际上我们想实现的逻辑仅仅是一个 a + b,其他代码其实都是冗余的,都是为了给编译器看的,并不是为了给程序员看的。有了比较我们就会发现,Lambda 表达式的简洁优雅的优势就凸显出来了。

为了方便大家使用,Java 内置了一些的函数式接口,放在 java.util.function 包中,比如 PredicateFunction 等,开发者可以根据自己需求实现这些接口。这里简单展示一下这两个接口。

Predicate 对输入进行判断,符合给定逻辑则返回 true,否则返回 false

@FunctionalInterface
public interface Predicate<T> {
    // 判断输入的真假,返回 boolean 类型值
    boolean test(T t);
}

Function 接收一个类型 T 的输入,返回一个类型 R 的输出。

@FunctionalInterface
public interface Function<T, R> {
     // 接收一个类型 T 的输入,返回一个类型 R 的输出
     R apply(T t);
}

部分底层代码提供了一些函数式接口供开发者调用,很多框架的 API 就是类似上面的函数式接口,开发者通过实现接口来完成自己的业务逻辑。Spark 和 Flink 对外提供的 Java API 其实就是这种函数式接口。

Java Stream API#

Stream API 是 Java 8 的一大亮点,它与 java.io 包里的 InputStreamOutputStream 是完全不同的概念,也不是 Flink、Kafka 等大数据流处理框架中的数据流。它专注于对集合(Collection)对象的操作,是借助 Lambda 表达式的一种应用。通过 Java Stream API,我们可以体验到 Lambda 表达式带来的编程效率的提升。

我们看一个简单的例子,代码清单 2-7 首先过滤出非空字符串,然后求得每个字符串的长度,最终返回为一个 List<Integer> 类型值。代码使用了 Lambda 表达式来完成对应的逻辑。

List<String> strings = Arrays.asList(
  "abc", "", "bc", "12345",
  "efg", "abcd","", "jkl");

List<Integer> lengths = strings
  .stream()
  .filter(string -> !string.isEmpty())
  .map(s -> s.length())
  .collect(Collectors.toList());

lengths.forEach((s) -> System.out.println(s));

代码清单 2-7 使用 Lambda 表达式来完成对 String 类型列表的操作

这段代码中,数据先经过 stream() 方法被转换为一个 Stream 类型,后经过 filter()map()collect() 等处理逻辑,生成我们所需的输出。各个操作之间使用英文点号 . 来连接,这种方式被称作方法链(Method Chaining)或者链式调用。链式调用可以被抽象成一个管道(Pipeline),将代码清单 2-7 进行抽象,可以形成 图 2.3 所示的 Stream 管道。

../_images/stream.png

图 2.3 Stream 管道#

函数式编程小结#

函数式编程更符合数学上函数映射的思想。具体到编程语言层面,我们可以使用 Lambda 表达式来快速编写函数映射,函数之间通过链式调用连接到一起,完成所需业务逻辑。Java 的 Lambda 表达式是后来才引入的,而 Scala 天生就是为函数式编程所设计。由于在并行处理方面的优势,函数式编程正在被大量应用于大数据处理领域。

对 Lambda 表达式、Java Stream API 以及 Flink API 有了基本了解后,我们也应该注意不要将 Java Stream API 与 Flink API 混淆。