拨开荷叶行,寻梦已然成。仙女莲花里,翩翩白鹭情。
IMG-LOGO
主页 文章列表 Java函数式编程

Java函数式编程

白鹭 - 2022-11-26 727 0 0

1.简介

在本教程中,我们将了解函数式编程范式的核心原理以及如何在Java编程语言中实践它们。我们还将介绍一些高级功能编程技术。

这也将使我们能够评估从函数式编程(尤其是Java)中获得的收益。

2. 什么是函数式编程

基本上,函数式编程是一种编写计算机程序的样式,该程序将计算视为评估数学函数。那么,数学中的函数是什么?

函数是将输入集与输出集相关联的表达式。

重要的是,函数的输出仅取决于其输入。更有趣的是,我们可以将两个或多个函数组合在一起以获得一个新函数。

2.1 Lambda微积分

要了解为什么这些数学函数的定义和属性在编程中很重要,我们必须倒退一些时间。在1930年代,数学家Alonzo Chruch开发了一个正式的系统来表达基于函数抽象的计算。这种通用的计算模型被称为Lambda微积分。

Lambda演算对开发编程语言(特别是函数式编程语言)的理论产生了巨大影响。通常,函数式编程语言实现lambda演算。

由于lambda演算着重于功能组合,因此功能性编程语言提供了表达功能的方法, 来构成功能组合中的软件。

2.2 函数式编程分类

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

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

相反,声明性方法表达了计算的逻辑,而没有按照语句序列描述其控制流程。简而言之,声明式方法的重点是定义程序必须达到的目标,而不是应该如何实现。函数式编程是声明性编程语言的子集。

这些类别还有其他子类别,并且分类法变得相当复杂,但是在本教程中我们将不再赘述。

2.3 编程语言的分类

今天,对编程语言进行正式分类的任何尝试本身就是一项学术工作!但是,我们将尝试根据对函数式编程的支持来了解如何对编程语言进行划分。

像Haskell这样的纯函数式语言仅允许纯函数式程序。

但是,其他语言既允许功能程序也允许程序程序,因此被认为是不纯功能语言。许多语言都属于这一类,包括Scala,Kotlin和Java。

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

3. 基本原理和概念

本节将介绍一些函数式编程的基本原理以及如何在Java中采用它们。请注意,我们将要使用的许多功能并不总是Java的一部分,建议使用Java 8或更高版本来有效地执行函数式编程

3.1 一等和高阶函数

如果编程语言将函数视为一等公民,则称该语言具有一等函数。基本上,这意味着允许功能支持其他实体通常可用的所有操作。这些包括将函数分配给变量,将它们作为参数传递给其他函数,以及将它们作为其他函数的值返回。

该属性使得可以在函数式编程中定义高阶函数。高阶函数能够将函数作为参数接收并作为结果返回函数。这进一步启用了功能编程中的几种技术,例如函数组合和函数柯里化(Currying)。

传统上,只能使用功能接口或匿名内部类之类的构造在Java中传递函数。功能接口只有一种抽象方法,也称为单一抽象方法(SAM)接口。

Collections.sort方法提供一个自定义比较器:

Collections.sort(numbers, new Comparator() { @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表达式视为Object ,实际上,它是Java中真正的一等公民。

3.2 纯函数

纯函数的定义强调纯函数应仅基于参数返回值,并且没有副作用。现在,这听起来很违反Java中的所有最佳实践。

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

当然,封装和其他面向对象的原则仅是建议,在Java中不是绑定。实际上,开发人员最近已经开始意识到定义不可变状态和方法而没有副作用的价值。

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

Integer sum(Listnumbers) { return numbers.stream().collect(Collectors.summingInt(Integer::intValue));

 }

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

副作用可以是除方法预期行为以外的任何东西。例如,副作用可以很简单,例如在返回值之前更新本地或全局状态或保存到数据库。纯粹主义者也将伐木视为副作用,但是我们都有自己的界限要设置!

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

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

3.3 不变性

不变性是函数式编程的核心原则之一,它是指实体在实例化后无法修改的属性。现在,在功能性编程语言中,语言级别的设计对此提供了支持。但是,在Java中,我们必须自行决定创建不可变的数据结构。

请注意, Java本身提供了几种内置的不可变类型,例如String 。这主要是出于安全原因,因为我们String并将其作为基于哈希的数据结构中的键。还有其他一些内置的不可变类型,例如原始包装器和数学类型。

但是我们用Java创建的数据结构又如何呢?当然,默认情况下它们不是不可变的,我们必须进行一些更改以实现不可变性。使用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中处理不可变量据更加容易。例如,Immutables和Project Lombok提供了现成的框架,用于在Java中定义不可变量据结构。

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.util.function包下为我们提供了许多针对不同用例的功能接口。

这些功能接口中的许多功能都以default方法和static让我们选择“ Function界面以更好地理解这一点。 Function是一个简单且通用的函数接口,它接受一个参数并产生结果。

它还提供了两个默认方法composeandThen ,这将有助于我们进行函数组合:

Functionlog = (value) -> Math.log(value);

 Functionsqrt = (value) -> Math.sqrt(value);

 FunctionlogThenSqrt = sqrt.compose(log);

 logger.log(Level.INFO, String.valueOf(logThenSqrt.apply(3.14))); // Output: 1.06

 FunctionsqrtThenLog = sqrt.andThen(log);

 logger.log(Level.INFO, String.valueOf(sqrtThenLog.apply(3.14))); // Output: 0.57

这两种方法都允许我们将多个功能组合为一个功能,但提供不同的语义。虽然compose应用在参数中传递的函数,然后再应用对其调用的函数,然后andThen执行相同的操作。

几个其他功能接口具有令人感兴趣的方法在功能上组合物中使用,如在默认的方法and, or ,和negatePredicate接口。尽管这些功能接口接受单个参数,但是有两个领域的特殊化,例如BiFunctionBiPredicate

4.2 Monads

许多函数式程序设计概念都源于范畴论,范畴论是数学中一般的函数理论。它提出了一些类别的概念,例如函子和自然变换。对于我们来说,唯一重要的是要知道这是在函数式编程中使用monad的基础。

从形式上讲,monad是一种抽象,它允许按一般方式构造程序。因此,从根本上讲,monad允许我们包装一个值,应用一组转换并在应用了所有转换的情况下取回值。当然,任何monad都需要遵循以下三个定律-左身份,右身份和关联性-但我们不赘述。

在Java中,我们经常使用一些monad,例如OptionalStream

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

现在,为什么我们将Optional称为monad?在这里, Optional允许我们使用该方法来包装一个值of并应用一系列的变换。 flatMap方法添加另一个包装值的转换。

如果需要,我们可以证明Optional遵循单子的三个定律。但是,批评家会很快指出, Optional议定书》的确违反了单子法。但是,对于大多数实际情况,这对我们来说应该足够了。

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

当然,我们可以在Java中定义自己的monad类型,以实现不同的目标,例如log monad,report monad或audit monad。还记得我们在函数式编程中讨论过如何处理副作用吗?好吧,看起来,monad是实现该功能的一种功能编程技术。

4.3 函数Currying

函数Currying是一种数学技术,可将带有多个参数的函数转换成带有单个参数的函数序列。但是,为什么在函数式编程中需要它们?它为我们提供了一种强大的组合技术,无需调用所有参数的函数。

而且,curried函数在接收所有参数之前不会实现其效果。

在纯函数式编程语言(例如Haskell)中,很好地支持currying。实际上,默认情况下所有函数都是咖哩的。但是,在Java中并不是那么简单:

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

 FunctionweightOnEarth = weight.apply(9.81);

 logger.log(Level.INFO, "My weight on Earth: " + weightOnEarth.apply(60.0));

 FunctionweightOnMars = weight.apply(3.75);

 logger.log(Level.INFO, "My weight on Mars: " + weightOnMars.apply(60.0));

在这里,我们定义了一个函数来计算我们在行星上的重量。虽然我们的质量保持不变,但重力因我们所处的星球而异。我们可以通过仅传递重力来为特定行星定义函数来部分应用该函数。而且,我们可以将此部分应用的函数作为参数或返回值传递给任意组合。

咖哩依赖于语言来提供两个基本特征:lambda表达式和闭包。 Lambda表达式是匿名函数,可帮助我们将代码视为数据。前面我们已经看到了如何使用功能接口来实现它们。

现在,lambda表达式可能会在其词法范围(我们将其定义为闭包)上关闭。让我们来看一个例子:

private static FunctionweightOnEarth() { final double gravity = 9.81; return mass -> mass * gravity;

 }

请注意,我们在上述方法中返回的lambda表达式如何取决于封闭变量(我们称为闭包)。与其他功能编程语言不同, Java的局限性在于封闭范围必须是final或有效的final

作为一个有趣的结果,currying还允许我们在Java中创建任意接口的功能接口。

4.4 递归

递归是函数式编程中的另一项强大技术,它使我们可以将问题分解为更小的部分。递归的主要好处是它可以帮助我们消除副作用,这是任何命令式样式循环所特有的。

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

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

 }

在这里,我们递归调用相同的函数,直到达到基本情况,然后开始计算结果。注意,我们在进行计算之前要进行递归调用,即在每一步或以单词开头计算结果。因此,这种递归样式也称为head递归

这种递归的缺点是,每个步骤都必须保持所有先前步骤的状态,直到我们到达基本情况为止。对于小数而言,这并不是真正的问题,但是对于大数保持状态可能是低效的。

解决方案是称为尾递归的递归的实现稍有不同。在这里,我们确保递归调用是函数进行的最后一次调用。让我们看看如何重写上面的函数以使用尾部递归:

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中参数的默认评估策略是eager 。但是,在函数式编程中,惰性评估是一种更有效和推荐的方法。

我们仍然可以使用运算符短路和功能接口在Java中实现惰性评估,但是它涉及更多。

该列表肯定是不完整的,可能包括带有类型擦除的泛型支持,缺少对尾部调用优化的支持等。但是,我们有一个广泛的想法。 Java绝对不适合在函数式编程中从头开始编写程序

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

对于Java开发人员来说,函数式编程的大部分好处就在于此。将面向对象的程序设计与功能性程序设计的好处相结合可以走很长的路要走

7.结论

在本教程中,我们介绍了函数式编程的基础知识。我们介绍了基本原理以及如何在Java中采用它们。此外,我们使用Java中的示例讨论了函数式编程中的一些流行技术。

最后,我们介绍了采用函数式编程的一些好处,并回答了Java是否适合使用函数式编程。

标签:

0 评论

发表评论

您的电子邮件地址不会被公开。 必填的字段已做标记 *