Java 函数式编程范式

1. 概述

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

我们还将介绍一些高级函数式编程技术。这将帮助我们了解 Java 中的函数式编程的好处。

2. 什么是函数式编程?

基本上,函数式编程是一种编程风格,它将计算看作为是数学函数的求值。

在数学中,函数是将输入集与输出集相关联的表达式。函数的输出仅取决于其输入。我们也可以将两个或多个函数组合在一起得到一个新函数。

2.1 Lambda 演算(lambda calculus)

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

在 1930 年代,数学家 Alonzo Church 开发了一个 formal 系统来表达基于函数抽象的计算。这种通用的计算模型后来被称为 lambda 演算。

Lambda 演算对编程语言理论的发展产生了巨大的影响,特别是函数式编程语言。一般来说,函数式编程语言实现 lambda 演算。

由于 lambda 演算专注于函数组合,函数式编程语言提供了在函数组合中组合软件的表达方式。

2.2 编程范式的分类

当然,函数式编程并不是实践中唯一的编程风格。从广义上讲,编程风格可以分为命令式和声明式编程范式。

  • 命令式方法将程序定义为一系列语句,这些语句改变程序的状态,直到它达到最终状态。
  • 过程式编程是一种命令式编程,我们使用过程或子例程构造程序。面向对象编程 (OOP) 扩展了过程编程概念。

相比之下,声明性方法表达了计算的逻辑,而不用一系列语句来描述其控制流。

简单地说,声明式方法的重点是定义程序必须实现什么,而不是它应该如何实现。函数式编程是声明式编程语言的一个子集。

这些类别还有更多的子类别,分类法变得相当复杂,但我们不会在本教程中深入讨论。

2.3 编程语言的分类

现在我们将尝试了解编程语言是如何根据它们对函数式编程的支持来划分的。

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

其他语言同时允许函数式和过程式程序,并被认为是不纯的函数式语言。许多语言都属于这一类,包括 Scala,Kotlin 和 Java。

重要的是要了解,当今大多数流行的编程语言都是通用语言,因此它们倾向于支持多种编程范式。

3 基本原理和概念

本节将介绍函数式编程的一些基本原则以及如何在 Java 中采用它们。

下面的例子,我们将使用 Java 8

3.1 一阶和高阶函数

如果将函数视为一等公民,则称编程语言具有一等函数。

这意味着允许函数支持其他实体通常可用的所有操作。其中包括将函数分配给变量,将它们作为参数传递给其他函数并将它们作为值从其他函数返回。

这个属性使得在函数式编程中定义高阶函数成为可能。高阶函数能够接收函数作为参数并返回函数作为结果。这进一步实现了函数式编程中的多种技术,例如函数组合和柯里化。

传统上,在 Java 中只能使用函数式接口或匿名内部类等结构来传递函数。函数式接口只有一种抽象方法,也称为单一抽象方法 (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));

这肯定更简洁易懂。

但是,请注意,虽然这可能会给我们一种在 Java 中使用函数作为一等公民的印象,但事实并非如此。

在 lambda 表达式的语法糖背后,Java 仍然将它们包装成函数式接口。因此,Java 将 lambda 表达式视为对象,这是 Java 中真正的一等公民。

3.2 纯函数

纯函数的定义强调纯函数应该只根据参数返回一个值,并且没有任何副作用。

除了方法的预期行为之外,副作用可以是任何东西。例如,副作用可以是更新本地或全局状态,或在返回值之前保存到数据库。 (有些人也将日志记录视为副作用。)

这听起来与 Java 中的所有最佳实践完全相反。

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

当然,封装和其他面向对象的原则只是建议,在 Java 中并不具有约束力。

事实上,开发人员最近开始意识到定义不可变状态的价值和定义没有副作用的方法的价值。

假设我们想要找到我们刚刚排序的所有数字的总和:

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

此方法仅取决于它接收的参数,因此它是确定性的。此外,它不会产生任何副作用。

那么,让我们看看我们如何处理合法的副作用。例如,出于真正的原因,我们可能需要将结果保存在数据库中。函数式编程中有一些技术可以在保留纯函数的同时处理副作用。

我们将在后面的部分讨论其中的一些。

3.3 不变性

不变性是函数式编程的核心原则之一,它是指实体在实例化后无法修改的属性。

在函数式编程语言中,这是由语言级别的设计支持的。但是在 Java 中,我们必须自己决定创建不可变的数据结构。

请注意,Java 本身提供了几种内置的不可变类型,例如 String。这主要是出于安全原因,因为我们在类加载和基于散列的数据结构中大量使用字符串作为键。还有其他几种内置的不可变类型,例如原始包装器和数学类型。

但是我们在 Java 中创建的数据结构呢?当然,默认情况下它们不是不可变的,我们必须进行一些更改才能实现不可变。

final 关键字的使用就是其中之一,但并不止于此:

public class ImmutableExample {
    private final String data;
    private final AnotherImmutableExample anotherImmutableExample;
    public ImmutableExample(final String data, final AnotherImmutableExample anotherImmutableExample) {
        this.data = data;
        this.anotherImmutableExample = anotherImmutableExample;
    }
    public String getData() {
        return data;
    }
    public AnotherImmutableExample getAnotherImmutableExample() {
        return anotherImmutableExample;
    }
}

public class AnotherImmutableExample {
    private final Integer otherData;
    public AnotherImmutableExample(final Integer data) {
        this.otherData = data;
    }
    public Integer getOtherData() {
        return otherData;
    }
}

请注意,我们必须努力遵守一些规则:

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

每次都完全正确并不容易,尤其是当数据结构开始变得复杂时。

但是,一些外部库可以使在 Java 中处理不可变数据变得更加容易。例如,Immutables 和 Project Lombok 提供了现成的框架,用于在 Java 中定义不可变数据结构。

3.4 引用透明性

引用透明性可能是函数式编程更难理解的原则之一,但这个概念非常简单。

如果将表达式替换为对应的值对程序的行为没有影响,我们就称其为引用透明的。

这使得在函数式编程中出现一些强大的技术,例如高阶函数和惰性求值。

为了更好地理解这一点,让我们举个例子:

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

这是 Java 中典型的 POJO 类,但我们有兴趣了解它是否提供了引用透明性。

让我们观察以下语句:

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

对 logger 的三个调用在语义上是等效的,但在引用上不透明。

第一次调用不是引用透明的,因为它会产生副作用(日志输出)。如果我们像在第三次调用中那样用它的值替换这个调用,我们将错过日志。

由于 SimpleExample 是可变的,第二个调用也不是引用透明的。在程序中的任何地方调用 data.setData 都会使其难以被替换成新值。

因此,为了引用透明性,我们需要我们的函数是纯粹的和不可变的。这是我们前面讨论的两个前提条件。

作为引用透明性的一个有趣结果,我们生成了上下文无关代码。换句话说,我们可以以任何顺序和上下文运行它们,从而产生不同的优化可能性。

4 函数式编程技术

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

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

4.1 函数组合

函数组合是指通过组合更简单的函数来组合复杂的函数。

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

通常,任何具有单个抽象方法的接口都可以用作功能接口。所以,我们可以很容易地定义一个函数式接口。

但是,Java 8 在 java.util.function 包下默认为我们提供了许多功能接口用于不同的用例。

这些函数式接口中的许多都提供了对默认和静态方法方面的函数组合的支持。让我们选择 Function 接口来更好地理解这一点。

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

它还提供了两个默认方法,compose 和 andThen,这将帮助我们进行函数组合:

Function<Double, Double> ceil = (value) -> Math.ceil(value);
Function<Double, Double> sqrt = (value) -> Math.sqrt(value);
Function<Double, Double> ceilThenSqrt = sqrt.compose(ceil);
logger.log(Level.INFO, String.valueOf(ceilThenSqrt.apply(5)));
// Output: 2.236

Function<Double, Double> sqrtThenCeil = sqrt.andThen(ceil);
logger.log(Level.INFO, String.valueOf(sqrtThenCeil.apply(5)));
// Output: 3.0

这两种方法都允许我们将多个函数组合成一个函数,但提供不同的语义。虽然 compose 首先应用在参数中传递的函数,然后是调用它的函数,andThen 反过来执行相同的操作。

其他几个函数式接口在函数组合中使用了有趣的方法,例如 Predicate 接口中的默认方法 andor 和 negate。虽然这些函数式接口接受一个参数,但也有两个特殊的参数,例如 BiFunction 和 BiPredicate

4.2 函子(Monads)

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

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

形式上,monad 是一种抽象,它允许一般地构建程序。因此,monad 允许我们包装一个值,应用一组转换,并在应用了所有转换后取回该值。

当然,任何单子都需要遵循三个定律——左身份、右身份和结合性——但我们不会在这里详细介绍。

在 Java 中,有一些我们经常使用的 monad,例如 Optional 和 Stream:

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

为什么我们称 Optional 为 monad?

这里 Optional 允许我们使用 of 方法包装一个值并应用一系列转换。我们正在使用 flatMap 方法应用添加另一个包装值的转换。

我们可以证明 Optional 遵循单子的三个定律。然而,一个 Optional 在某些情况下确实违反了单子定律。但对于大多数实际情况,它对我们来说应该足够好了。

如果我们了解了 monad 的基础知识,我们很快就会意识到 Java 中还有很多其他的例子,比如 Stream 和 CompletableFuture。它们帮助我们实现不同的目标,但它们都具有处理上下文操作或转换的标准组合。

当然,我们可以在 Java 中定义自己的 monad 类型来实现不同的目标,monad 是处理函数式编程中副作用的函数式编程技术之一。

4.3 柯里化(Currying)

柯里化(Currying)是一种数学技术,可以将采用多个参数的函数转换为采用单个参数的函数序列。

在函数式编程中,它为我们提供了一种强大的组合技术,我们不需要调用带有所有参数的函数。

此外,柯里化函数在接收到所有参数之前不会实现其效果。

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

然而,在 Java 中并不是那么简单:

Function<Double, Function<Double, Double>> rmb = amount -> exchangeRate -> amount * exchangeRate;

Function<Double, Double> rmbOnDollar = rmb.apply(6.7);
logger.log(Level.INFO, "RMB for 100 dollars: " + rmbOnDollar.apply(100));

Function<Double, Double> rmbOnEuro = rmb.apply(6.9);
logger.log(Level.INFO, "RMB for 100 Euros: " + rmbOnEuro.apply(100));

在这里,我们定义了一个函数来计算我们兑换后的人民币金额。虽然我们要兑换的特定货币总数保持不变,但汇率对于不同的货币是不同的。

我们可以通过仅传递汇率来部分应用该函数来为特定货币定义一个函数。此外,我们可以将这个部分应用的函数作为参数或返回值传递给任意组合。

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

一个 lambda 表达式可以关闭它的词法作用域,我们将其定义为它的闭包。

让我们看一个例子:

private static Function<Double, Double> rmbOnDollar() { 
    final double exchangeRate = 6.7;    
    return amount -> amount * exchangeRate;
}

请注意,我们在上述方法中返回的 lambda 表达式如何依赖于封闭变量,我们称之为闭包。与其他函数式编程语言不同,Java 有一个限制,即封闭范围必须是 final 的或有效的 final。Currying 还允许我们在 Java 中创建任意数量的函数式接口。

4.4 递归

递归是函数式编程中另一种强大的技术,它允许我们将问题分解成更小的部分。递归的主要好处是它可以帮助我们消除副作用,这是任何命令式循环的典型特征。

让我们看看我们如何使用递归计算一个数字的阶乘:

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

在这里,我们递归调用相同的函数,直到达到最小的 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 中采用函数式编程应该有一些非常有前途的优势。

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

声明式编程导致程序非常简洁易读。作为声明式编程的一个子集,函数式编程提供了几种结构,例如高阶函数、函数组合和函数链。想想 Stream API 为 Java 8 带来的处理数据操作的好处。

但是除非完全准备好,否则不要试图切换。请注意,函数式编程不是我们可以立即使用并从中受益的简单设计模式。

函数式编程更多地改变了我们对问题及其解决方案的推理方式以及如何构建算法。

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

6. Java 适合吗?

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

从历史上看,Java 变成了更适合面向对象编程的通用编程语言。即使在 Java 8 之前考虑使用函数式编程也很乏味!但是在 Java 8 之后情况肯定发生了变化。

Java 中没有真正的函数类型这一事实违背了函数式编程的基本原则。伪装成 lambda 表达式的函数式接口在很大程度上弥补了这一点,至少在语法上是这样。

Java 中的类型本质上是可变的,我们必须编写很多样板来创建不可变类型。

我们期望从函数式编程语言中获得 Java 中缺少或困难的其他东西。例如,Java 中参数的总是在方法调用前就已经确定了,但是惰性求值是函数式编程中更有效和推荐的方式。还有例如对类型擦除的泛型支持,缺少对尾调用优化的支持和其他内容。但是,我们有一个广泛的想法。

我们仍然可以使用运算符短路和函数式接口在 Java 中实现惰性求值,但它涉及的更多。

运算符短路:当有多个表达式时,左边的表达式值可以确定结果时,就不再继续运算右边的表达式的值。

Java 绝对不适合在函数式编程中从头开始编写程序。

但是,如果我们已经有一个用 Java 编写的现有程序,可能是面向对象编程的呢?没有什么能阻止我们获得函数式编程的一些好处,尤其是在 Java 8 中。

这就是函数式编程对 Java 开发人员的大部分好处所在。将面向对象编程与函数式编程的优点相结合可以大有帮助。

请登录后发表评论

    没有回复内容