1. 概述

在本教程中,我们将理解函数式编程范式的核心原则,以及如何在 Java 编程语言中实践它们。

我们还将介绍一些高级函数式编程技巧。

这有助于我们评估函数式编程带来的好处,特别是在 Java 中。

2. 什么是函数式编程?

简单来说,函数式编程 是一种 将计算视为数学函数求值的编程风格

在数学中,函数是将输入集映射到输出集的表达式。

重要的是,函数的输出只依赖于其输入。更有趣的是,我们可以将两个或多个函数组合起来,形成一个新的函数。

2.1. Lambda 演算

为了理解为什么这些数学函数的定义和性质在编程中如此重要,我们需要稍微回顾一下历史。

在 1930 年代,数学家阿隆佐·邱奇开发了 一种基于函数抽象来表达计算的形式系统。这种通用的计算模型后来被称为 lambda 演算

Lambda 演算对编程语言理论的发展产生了巨大影响,尤其是函数式编程语言。通常,函数式编程语言会实现 lambda 演算。

由于 lambda 演算专注于函数组合,函数式编程语言提供了丰富的手段来以函数组合的方式构建软件。

2.2. 编程范式的分类

当然,函数式编程并不是唯一的编程风格。广义上,编程风格可以分为命令式和声明式两大类。

命令式编程 将程序定义为一系列改变程序状态的语句,直到达到最终状态。

过程式编程是命令式编程的一种,它通过过程或子程序来构建程序。而广为人知的 面向对象编程(OOP) 则是对过程式编程概念的扩展。

与之相对,声明式编程 表达计算逻辑时 不描述控制流,即不依赖一系列语句。

简单来说,声明式编程的重点是 定义程序要达成什么目标,而不是如何达成。函数式编程是声明式编程的一个子集。

这些分类还可以进一步细分,但本教程不再深入。

2.3. 编程语言的分类

接下来我们将了解编程语言在支持函数式编程方面的分类。

纯函数式语言,如 Haskell,只允许纯函数式程序。

其他语言允许 函数式和过程式程序共存,这类语言被称为“不纯”的函数式语言。许多语言属于这一类,包括 Scala、Kotlin 和 Java。

需要指出的是,如今大多数主流编程语言都是通用语言,因此它们通常支持多种编程范式。

3. 函数式编程的基本原则与概念

本节将介绍函数式编程的一些基本原则,并探讨如何在 Java 中实践它们。

请注意,许多我们将要使用到的特性并非一直存在于 Java 中,建议使用 Java 8 或更高版本 来有效实践函数式编程。

3.1. 一等函数与高阶函数

如果一门编程语言将函数视为一等公民,我们就说它支持一等函数。

这意味着 函数可以支持所有通常用于其他实体的操作,比如将函数赋值给变量、作为参数传递给其他函数,或从其他函数中返回。

这一特性使得定义高阶函数成为可能。高阶函数是指可以接收函数作为参数,或返回函数作为结果的函数。这进一步支持了函数式编程中的多种技术,如函数组合和柯里化。

在 Java 8 之前,我们只能通过函数式接口或匿名内部类来传递函数。函数式接口只有一个抽象方法,也被称为单抽象方法(SAM)接口。

假设我们要为 Collections.sort 方法提供一个自定义比较器:

Collections.sort(numbers, new Comparator<Integer>() {
    @Override
    public int compare(Integer n1, Integer n2) {
        return n1.compareTo(n2);
    }
});

可以看到,这种方式繁琐且冗长——显然不利于开发者拥抱函数式编程。

幸运的是,Java 8 带来了许多 新特性来简化这一过程,如 Lambda 表达式、方法引用和预定义的函数式接口

让我们看看 Lambda 表达式如何简化同样的任务:

Collections.sort(numbers, (n1, n2) -> n1.compareTo(n2));

这显然更简洁、易读。

不过请注意,虽然 Lambda 表达式让我们 感觉 函数在 Java 中是一等公民,但事实并非如此。

Lambda 表达式在语法上只是一个糖衣,Java 依然将它们包装成函数式接口。因此,Java 实际上是将 Lambda 表达式视为对象(Object),这才是真正的“一等公民”。

3.2. 纯函数

纯函数的定义强调:纯函数的返回值必须仅依赖于输入参数,并且没有副作用

这听起来与 Java 的最佳实践有些冲突。

作为一门面向对象语言,Java 推荐封装作为核心编程实践。它鼓励隐藏对象的内部状态,只暴露必要的方法来访问和修改。因此,这些方法并不是严格意义上的纯函数。

当然,封装和其他面向对象原则只是建议,不是强制的。

实际上,近年来开发者越来越意识到定义不可变状态和无副作用方法的价值。

比如,我们要计算排序后的数字总和:

Integer sum(List<Integer> numbers) {
    return numbers.stream().collect(Collectors.summingInt(Integer::intValue));
}

该方法仅依赖传入的参数,因此是确定性的。此外,它没有产生任何副作用。

副作用可以是除了方法预期行为之外的任何操作。例如,副作用可以是更新局部或全局状态,或在返回值前保存到数据库。(严格主义者甚至认为日志也是一种副作用。)

那么,我们该如何处理合法的副作用呢?例如,我们可能需要将结果保存到数据库。函数式编程中有一些技术可以在保留纯函数的同时处理副作用。

我们将在后续章节中讨论这些技术。

3.3. 不可变性

不可变性是函数式编程的核心原则之一,它指的是 实体在实例化后不能被修改

在函数式编程语言中,不可变性是语言层面的支持。但在 Java 中,我们需要自己决定如何创建不可变数据结构。

请注意,Java 本身提供了一些内置的不可变类型,例如 String。这主要是出于安全考虑,因为 String 在类加载和哈希结构中被大量使用。其他内置不可变类型还包括基本类型的包装类和数学类。

那么我们自己创建的数据结构呢?它们默认是可变的,我们需要做出一些改变才能实现不可变性。

使用 final 关键字是其中一种方式,但不止于此:

public class ImmutableData {
    private final String someData;
    private final AnotherImmutableData anotherImmutableData;
    public ImmutableData(final String someData, final AnotherImmutableData anotherImmutableData) {
        this.someData = someData;
        this.anotherImmutableData = anotherImmutableData;
    }
    public String getSomeData() {
        return someData;
    }
    public AnotherImmutableData getAnotherImmutableData() {
        return anotherImmutableData;
    }
}

public class AnotherImmutableData {
    private final Integer someOtherData;
    public AnotherImmutableData(final Integer someData) {
        this.someOtherData = someData;
    }
    public Integer getSomeOtherData() {
        return someOtherData;
    }
}

注意我们需要严格遵守以下规则:

  • 不可变数据结构的所有字段必须是不可变的。
  • 所有嵌套类型和集合(包括它们包含的内容)也必须满足不可变性。
  • 应该有一个或多个构造函数用于初始化。
  • 只能有访问器方法,且不应有副作用。

实现完全正确的不可变结构并不容易,尤其是在数据结构变得复杂时。

不过,有一些外部库可以帮助我们更轻松地处理 Java 中的不可变数据。例如 ImmutablesProject Lombok 提供了现成的框架来定义不可变数据结构。

3.4. 引用透明性

引用透明性可能是函数式编程中最难理解的原则之一,但概念其实很简单。

我们说一个表达式是引用透明的,如果将它替换为它的值不会影响程序的行为。

这为函数式编程带来了强大的技术,如高阶函数和惰性求值。

为了更好地理解,来看一个例子:

public class SimpleData {
    private Logger logger = Logger.getGlobal();
    private String data;
    public String getData() {
        logger.log(Level.INFO, "Get data called for SimpleData");
        return data;
    }
    public SimpleData setData(String data) {
        logger.log(Level.INFO, "Set data called for SimpleData");
        this.data = data;
        return this;
    }
}

这是一个典型的 Java POJO 类,但我们关注的是它是否具备引用透明性。

观察以下语句:

String data = new SimpleData().setData("Baeldung").getData();
logger.log(Level.INFO, new SimpleData().setData("Baeldung").getData());
logger.log(Level.INFO, data);
logger.log(Level.INFO, "Baeldung");

三次 logger 调用在语义上是等价的,但不具备引用透明性。

第一次调用不具备引用透明性,因为它产生了副作用。如果我们将它替换为它的值(如第三次调用),就会丢失日志信息。

第二次调用也不具备引用透明性,因为 SimpleData 是可变的。程序中任何对 data.setData 的调用都会导致它无法被替换为值。

要实现引用透明性,我们的函数必须是纯函数且不可变。这是两个我们之前讨论的前提条件。

引用透明性的一个有趣结果是,我们生成了上下文无关的代码。换句话说,我们可以在任何顺序和上下文中运行它们,这为优化提供了更多可能性。

4. 函数式编程技巧

前面讨论的函数式编程原则使我们能够使用多种技术来从中受益。

在本节中,我们将介绍一些流行的技巧,并了解如何在 Java 中实现它们。

4.1. 函数组合

函数组合指的是 通过组合简单函数来构建复杂函数

在 Java 中,这主要是通过函数式接口来实现的,函数式接口是 Lambda 表达式和方法引用的目标类型。

通常,任何只有一个抽象方法的接口都可以作为函数式接口。因此,我们可以轻松定义一个函数式接口。

不过,Java 8 默认提供了许多函数式接口,用于不同的使用场景,位于 java.util.function 包中。

许多函数式接口通过 defaultstatic 方法支持函数组合。我们以 Function 接口为例来理解这一点。

Function 是一个简单的通用函数式接口,接受一个参数并产生一个结果。

它还提供了两个默认方法:composeandThen,用于函数组合:

Function<Double, Double> log = (value) -> Math.log(value);
Function<Double, Double> sqrt = (value) -> Math.sqrt(value);
Function<Double, Double> logThenSqrt = sqrt.compose(log);
logger.log(Level.INFO, String.valueOf(logThenSqrt.apply(3.14)));
// Output: 1.06
Function<Double, Double> sqrtThenLog = sqrt.andThen(log);
logger.log(Level.INFO, String.valueOf(sqrtThenLog.apply(3.14)));
// Output: 0.57

这两个方法都允许我们将多个函数组合成一个函数,但语义不同。compose 先执行传入的函数,再执行调用它的函数;而 andThen 则相反。

其他函数式接口也有 有趣的组合方法,例如 Predicate 接口中的 andornegate。虽然这些接口只接受一个参数,但也存在 双参数特化,如 BiFunctionBiPredicate

4.2. 单子(Monad)

许多函数式编程概念源于 范畴论,它是数学中关于函数的一般理论。它提出了诸如函子和自然变换等范畴概念。

对我们来说,只需要知道这是在函数式编程中使用单子的基础。

形式上,单子是一种抽象,允许我们以通用方式构建程序。因此,单子 允许我们包装一个值,应用一系列变换,并获取应用了所有变换后的值

当然,任何单子都必须遵循三个定律:左单位律、右单位律和结合律,但我们在此不展开。

在 Java 中,我们经常使用的一些单子包括 OptionalStream

Optional.of(2).flatMap(f -> Optional.of(3).flatMap(s -> Optional.of(f + s)))

为什么我们称 Optional 为单子?

因为 Optional 允许我们使用 of 方法包装值,并应用一系列变换。我们通过 flatMap 方法将另一个包装值加到当前值上。

我们可以说 Optional 遵循单子定律,但在某些情况下会违反。不过在大多数实际场景中,它已经足够好了。

如果我们理解单子的基本概念,很快就会发现 Java 中还有许多其他例子,如 StreamCompletableFuture。它们帮助我们实现不同的目标,但都有统一的组合方式来处理上下文或变换。

当然,我们也可以在 Java 中定义自己的单子类型,以实现不同的目标,如日志单子、报告单子或审计单子。例如,单子就是函数式编程中处理副作用的一种技术。

4.3. 柯里化(Currying)

柯里化 是一种数学技巧,将接受多个参数的函数转换为一系列接受单个参数的函数

在函数式编程中,它提供了一种强大的组合技术,我们不需要一次性传入所有参数。

此外,柯里化函数只有在接收到所有参数后才会执行。

在纯函数式语言如 Haskell 中,柯里化得到了很好的支持。事实上,所有函数默认都是柯里化的。

但在 Java 中则不那么直接:

Function<Double, Function<Double, Double>> weight = gravity -> mass -> mass * gravity;

Function<Double, Double> weightOnEarth = weight.apply(9.81);
logger.log(Level.INFO, "My weight on Earth: " + weightOnEarth.apply(60.0));

Function<Double, Double> weightOnMars = weight.apply(3.75);
logger.log(Level.INFO, "My weight on Mars: " + weightOnMars.apply(60.0));

这里我们定义了一个计算行星上体重的函数。虽然我们的质量保持不变,但重力因行星而异。

我们可以 通过只传入重力来部分应用函数,从而为特定行星定义函数。此外,我们可以将部分应用的函数作为参数传递或作为返回值进行任意组合。

柯里化 依赖语言提供的两个基本特性:Lambda 表达式和闭包。Lambda 表达式是匿名函数,帮助我们将代码视为数据。我们之前已经看到如何通过函数式接口来实现它们。

Lambda 表达式可能捕获其词法作用域,我们称之为闭包。

来看一个例子:

private static Function<Double, Double> weightOnEarth() {    
    final double gravity = 9.81;    
    return mass -> mass * gravity;
}

注意上面方法中返回的 Lambda 表达式如何依赖外部变量,这就是闭包。与其他函数式语言不同,**Java 有一个限制:外部作用域必须是 final 或 effectively final**。

有趣的是,柯里化还允许我们在 Java 中创建任意元数的函数式接口。

4.4. 递归

递归 是函数式编程中的另一项强大技术,允许我们将问题分解为更小的部分。递归的主要好处是它有助于消除命令式循环中常见的副作用。

让我们看看如何用递归计算阶乘:

Integer factorial(Integer number) {
    return (number == 1) ? 1 : number * factorial(number - 1);
}

这里我们递归调用同一函数,直到达到基础情况,然后开始计算结果。

注意我们在每一步计算结果前都进行了递归调用,这被称为 头递归

这种递归的缺点是每一步都必须保留之前所有步骤的状态,直到达到基础情况。对于小数字不是问题,但对于大数字来说效率较低。

解决方案是 尾递归,即确保递归调用是函数的最后一步。

让我们看看如何将上面的函数改写为尾递归:

Integer factorial(Integer number, Integer result) {
    return (number == 1) ? result : factorial(number - 1, result * number);
}

注意这里使用了累加器,消除了每一步保留状态的需求。这种风格的真正好处是可以利用编译器优化,例如尾调用消除。

虽然许多语言(如 Scala)支持尾调用消除,但 Java 目前还不支持。这是 Java 的待办事项之一,可能会在 Project Loom 的更大变革中实现。

5. 为什么函数式编程重要?

到目前为止,我们可能会问:为什么要付出这么多努力?对于一个 Java 背景的开发者来说,函数式编程所要求的转变并不简单。所以,采用函数式编程必须带来真正的好处。

在任何语言中(包括 Java)采用函数式编程的最大优势是 纯函数和不可变状态。回想一下,大多数编程挑战都源于副作用和可变状态。简单地消除它们 使我们的程序更易读、推理、测试和维护

声明式编程 使程序更加简洁和可读。作为声明式编程的子集,函数式编程提供了多种构造,如高阶函数、函数组合和函数链。想想 Stream API 为 Java 8 带来的数据处理便利。

但不要盲目切换,除非你已经完全准备好。请注意,函数式编程不是一种我们可以立即使用并受益的设计模式。

函数式编程 更多是一种思维方式的改变,关于如何理解问题及其解决方案,以及如何组织算法。

因此,在开始使用函数式编程之前,我们必须训练自己以函数的方式来思考程序。

6. Java 是否适合函数式编程?

很难否认函数式编程的好处,但 Java 是否适合呢?

从历史上看,Java 是一门更适用于面向对象编程的通用语言。在 Java 8 之前,使用函数式编程简直是噩梦!但 Java 8 之后情况发生了巨大变化。

Java 中没有真正的函数类型,这违背了函数式编程的基本原则。虽然 Lambda 表达式和函数式接口在语法上弥补了这一点,但本质并没有改变。

此外,Java 中的类型默认是可变的,我们需要编写大量样板代码来创建不可变类型。

我们期望函数式编程语言具备的一些特性在 Java 中缺失或难以实现。例如,Java 对参数的默认求值策略是急切的。但惰性求值在函数式编程中更高效且推荐。

我们仍然可以通过操作符短路和函数式接口在 Java 中实现惰性求值,但过程更复杂。

列表还不完整,还包括泛型的类型擦除、缺少尾调用优化等。但总体来看,我们已经有一个大致了解。

Java 绝对不适合从零开始用函数式编程来构建程序

但如果我们的程序已经存在,可能是面向对象编写的呢?没有任何东西阻止我们从中获得函数式编程的好处,尤其是在 Java 8 之后。

这正是大多数 Java 开发者从函数式编程中获益的地方。将面向对象编程与函数式编程的优点结合起来,可以走得更远

7. 结论

在本文中,我们回顾了函数式编程的基础知识。我们讨论了其基本原则以及如何在 Java 中实践它们。

此外,我们还介绍了函数式编程中的一些流行技巧,并提供了 Java 示例。

最后,我们讨论了采用函数式编程的优势,并回答了 Java 是否适合的问题。

本文的源代码可以在 GitHub 上找到。


原始标题:Functional Programming in Java | Baeldung