likes
comments
collection
share

《Java的函数式》第二章:函数式Java

作者站长头像
站长
· 阅读数 12

第二章:函数式Java

毫不奇怪,lambda表达式是在Java中实现函数式方法的关键。 在本章中,您将学习如何在Java中使用lambda表达式,为什么它们如此重要,如何高效地使用它们以及它们在内部是如何工作的。

Java Lambdas是什么?

一个lambda表达式是一行或一个代码块的Java代码,它可以有零个或多个参数,并且可能返回一个值。从简化的角度来看,lambda表达式就像是一个匿名方法,不属于任何对象:

() -> System.out.println("Hello, lambda!")

让我们来看看Java中lambda表达式的语法细节和实现方式。

Lambda语法

Java中lambda的语法与您在第1章中看到的lambda演算的数学表示非常相似:

(<parameters>)−><body>(<parameters>) -> { <body> }(<parameters>)><body>

语法由三个不同的部分组成:

参数:

逗号分隔的参数列表,就像方法的参数列表一样。不过,与方法参数不同的是,如果编译器能够推断出参数的类型,您可以省略参数类型。不允许混合使用隐式和显式类型的参数。如果只有一个参数,则不需要括号,但如果没有参数或多个参数,则需要使用括号。

箭头:

箭头(->)将参数与lambda体分隔开来。它相当于lambda演算中的λ。

主体:

可以是单个表达式或代码块。单行表达式不需要花括号,它们的计算结果将隐式返回,无需return语句。如果主体由多个表达式表示,则使用典型的Java代码块。它必须用花括号括起来,如果需要返回值,则必须显式使用return语句。

这就是Java中lambda的所有语法定义。通过不同的声明方式,您可以以不同的冗长程度编写相同的lambda表达式,如示例2-1所示:

    (String input) -> { 
  return input != null;
}

input -> { 
  return input != null;
}

(String input) -> input != null 

input -> input != null

选择哪种变体高度取决于上下文和个人偏好。通常,编译器可以推断出类型,但这并不意味着人类读者能像编译器一样善于理解最短的代码。 尽管您应该始终追求简洁和更简练的代码,但这并不意味着它必须尽可能地简约。适度的冗长可能有助于任何读者,包括您自己,更好地理解代码背后的推理,并且更容易理解您代码的心智模型。

函数式接口

到目前为止,我们只独立地看了lambda的一般概念。然而,它们仍然必须存在于Java及其概念和语言规则中。 Java以向后兼容性而闻名。因此,尽管lambda语法对Java语法本身来说是一个重大变化,但它仍然基于普通接口以实现向后兼容,并且对任何Java开发人员来说都非常熟悉。

为了实现它们的一流公民地位,Java中的lambda需要与现有类型(如对象和基本类型)相当的表示,正如在“一流和高阶函数”一节中讨论的那样。因此,lambda由专门的接口子类型表示,被称为函数式接口。

函数式接口没有明确的语法或语言关键字。它们看起来和感觉就像任何其他接口一样,可以扩展或被其他接口扩展,类可以实现它们。如果它们就像“普通”接口一样,那么是什么使它们成为“函数式”接口呢?这是因为它们强制要求只能定义一个抽象方法(SAM)。

正如名称所示,SAM计数仅适用于抽象方法。对于任何其他非抽象方法,没有限制。默认方法和静态方法都不是抽象方法,因此对SAM计数没有影响。这就是为什么它们通常用于补充lambda类型的能力。

考虑示例2-2,它展示了函数式接口java.util.function.Predicate的简化版本1。Predicate是用于测试条件的函数式接口,在“四个重要的函数式接口类别”中将详细介绍。除了具有一个抽象方法boolean test(T t)之外,它还提供了五个附加方法(三个默认方法和两个静态方法):

package java.util.function;

@FunctionalInterface 
public interface Predicate<T> {

  boolean test(T t); 

  default Predicate<T> and(Predicate<? super T> other) { 
    // ...
  }

  default Predicate<T> negate() { 
    // ...
  }

  default Predicate<T> or(Predicate<? super T> other) { 
    // ...
  }

  static <T> Predicate<T> isEqual(Object targetRef) { 
    // ...
  }

  static <T> Predicate<T> not(Predicate<? super T> target) { 
    // ...
  }
}

任何具有单个抽象方法的接口都自动成为函数式接口。因此,它们的任何实现也可以用lambda表示。

Java 8添加了标记注解@FunctionalInterface,用于在编译器级别强制执行SAM要求。它并非强制性的,但它告诉编译器和可能的基于注解的工具,一个接口应该是一个函数式接口,因此必须强制执行单个抽象方法的要求。如果你添加了另一个抽象方法,Java编译器将拒绝编译你的代码。这就是为什么对任何函数式接口添加注解都是有意义的,即使你并不明确需要它。它清楚地解释了你的代码的原因和接口的意图,并且在未来可以防止不经意的更改可能导致代码破坏。

@FunctionalInterface注解的可选性也使现有接口具备向后兼容性。只要一个接口满足SAM的要求,它就可以用lambda表示。我将在本章后面谈论JDK的函数式接口。

Lambdas和外部变量

“纯函数和引用透明性”介绍了纯函数的概念,纯函数是自包含的、无副作用的函数,不会影响任何外部状态,只依赖于它们的参数。虽然lambda表达式遵循相同的要点,但它们也允许一定程度的不纯性以增加灵活性。它们可以从定义lambda的创建作用域中“捕获”常量和变量,即使原始作用域不存在,这些变量也可以对它们可见,如示例2-3所示:

void capture() {
  var theAnswer = 42; 

  Runnable printAnswer =
    () -> System.out.println("the answer is " + theAnswer); 

  run(printAnswer); 
}

void run(Runnable r) {
  r.run();
}

capture();
// OUTPUT:
// the answer is 42

捕获和非捕获的lambda之间的主要区别在于JVM的优化策略。JVM根据lambda的实际使用模式采用不同的优化策略。如果没有变量被捕获,lambda可能会在幕后成为一个简单的静态方法,优于匿名类等替代方法的性能。然而,变量捕获对性能的影响并不是一清二楚的。

如果捕获变量,JVM可能会以多种方式转换您的代码,导致额外的对象分配,影响性能和垃圾回收器的时间。这并不意味着捕获变量本质上是一个不好的设计选择。更功能化的方法的主要目标应该是提高生产力,更简单的推理和更简洁的代码。然而,您应该避免不必要的捕获,特别是如果您需要最少的分配或最佳的性能。

避免捕获变量的另一个原因是它们必须是有效地final的。

有效地final

JVM必须特别考虑如何安全地使用捕获的变量并实现尽可能最佳的性能。这就是为什么有一个基本要求:只有实际上是final的变量才被允许被捕获。

简单来说,任何被捕获的变量必须是一个不可变的引用,在初始化后不允许改变。它们必须是final的,可以通过明确使用final关键字或在初始化后不进行改变来实现,从而使其实际上是final的。

需要注意的是,这个要求实际上是对变量的引用而不是底层数据结构本身的要求。对一个List的引用可能是final的,因此可以在lambda中使用,但仍然可以添加新的项目,就像在示例2-4中所示。只有重新分配变量是被禁止的:

final List<String> wordList = new ArrayList<>(); 

// COMPILES FINE
Runnable addItemInLambda = () -> wordList.add("adding is fine"); 

// WON'T COMPILE
wordList = List.of("assigning", "another", "List", "is", "not"); 

测试变量是否实际上是final的最简单方法是显式地将其声明为final。如果你的代码在额外添加final关键字后仍然能够编译通过,那么它在没有final关键字的情况下也能编译通过。那么为什么不将每个变量都声明为final呢?因为编译器会确保“超出方法体”的引用是实际上是final的,所以关键字在实际的不可变性方面并没有帮助。将每个变量都声明为final只会在你的代码中增加更多的视觉噪音,而没有太多的好处。像final这样的修饰符应该始终是有意识地进行决策的结果。

重新将引用声明为final

有时候一个引用可能不是实际上是final的,但你仍然需要在lambda中使用它。如果重构代码不是一个选项,有一个简单的技巧可以重新将其声明为final。记住,这个要求只是针对引用而不是底层的数据结构本身。

你可以通过简单地引用原始的变量而不进行进一步的更改,来创建一个新的实际上是final的引用,如示例2-5所示:

var nonEffectivelyFinal = 1_000L; 

nonEffectivelyFinal = 9_000L; 

var finalAgain = nonEffectivelyFinal; 

Predicate<Long> isOver9000 = input -> input > finalAgain;

请记住,重新将引用声明为final只是一种权宜之计,需要采用这种权宜之计意味着你首先受伤了。因此,最好的方法是尽量不需要这样做。与其通过重新将引用声明为final这样的技巧来弯曲代码以适应你的意愿,重构或重新设计你的代码应该始终是首选的选项。

像实际上是final的要求这样的对在lambda中使用变量的保护措施一开始可能会感觉像是额外的负担。然而,与其捕获“超出方法体”的变量,你的lambda应该努力自给自足,并将所有必要的数据作为参数传入。这自然地导致更合理的代码、增加的可重用性,并且便于进行重构和测试。

关于匿名类呢?

在学习了关于Lambda表达式和函数式接口之后,你很可能会想起它们与匿名内部类的相似之处:将类型的声明和实例化结合在一起。接口或扩展类可以“即时”地在不需要单独的Java类的情况下进行实现,那么如果它们都必须实现一个具体接口,Lambda表达式和匿名类之间有什么区别呢?

表面上看,由匿名类实现的函数式接口与其Lambda表示方式非常相似,只是有额外的样板代码,就像示例2-6中所示:

// FUNCTIONAL INTERFACE (implicit)
interface HelloWorld {
  String sayHello(String name);
}

// AS ANONYMOUS CLASS
var helloWorld = new HelloWorld() {
  @Override
  public String sayHello(String name) {
    return "hello, " + name + "!";
  }
};

// AS LAMBDA
HelloWorld helloWorldLambda = name -> "hello, " + name + "!";

这是否意味着Lambda表达式只是实现函数式接口的匿名类的语法糖呢?

Lambda表达式看起来可能像是语法糖,但实际上它们在本质上更加强大。除了语法冗长之外,真正的区别在于生成的字节码,就像示例2-7所示,以及运行时的处理方式:

// ANONYMOUS CLASS
0: new #7 // class HelloWorldAnonymous$1 
3: dup
4: invokespecial #9 // Method HelloWorldAnonymous$1."<init>":()V 
7: astore_1
8: return

// LAMBDA
0: invokedynamic #7, 0 // InvokeDynamic #0:sayHello:()LHelloWorld; 
5: astore_1
6: return

这两种变体都有共同的astore_1调用,它将引用存储到局部变量中,以及return调用,因此两者都不会成为字节码分析的一部分。 匿名类版本创建了一个匿名类型HelloWorldAnonymous$1的新对象,产生了三个操作码:

new

创建一个未初始化的类型实例。

dup

通过复制将值放在栈顶。

invokespecial

调用新创建对象的构造方法来完成初始化。

另一方面,Lambda版本不需要创建一个需要放在栈上的实例。相反,它使用单个操作码invokedynamic将创建Lambda的整个任务委托给JVM。

Lambda表达式和匿名内部类之间的另一个重大区别是它们各自的作用域。内部类创建了自己的作用域,将其局部变量隐藏在封闭作用域之外。这就是为什么关键字this引用的是内部类实例本身,而不是周围的作用域。另一方面,Lambda完全存在于其周围的作用域中。变量不能以相同的名称重新声明,而this引用的是Lambda创建所在的实例,如果不是静态的话。

正如你所看到的,Lambda表达式并不是纯粹的语法糖。

Lambda实战

正如你在前面的部分中所看到的,Lambda表达式是Java中的一个非凡的增加,用于提高其函数式编程能力,远远超出了以前可用方法的语法糖。它们作为一等公民具有静态类型、简洁和匿名函数的特性,就像任何其他变量一样。虽然箭头语法可能是新的,但整体的使用模式对任何程序员来说应该是熟悉的。在本节中,我们将直接进入使用Lambda并观察它们的实际应用。

创建Lambda

要创建一个Lambda表达式,你需要表示一个单一的函数式接口。实际的类型可能不明显,因为接收方法参数决定了所需的类型,或者编译器会在可能的情况下进行推断。

让我们再次看一下Predicate,以更好地说明这一点。 创建一个新的实例需要在左侧定义类型:

Predicate<String> isNull = value -> value == null;

即使你对参数使用显式类型,函数式接口的类型仍然是必需的:

// WON'T COMPILE
var isNull = (String value) -> value == null;

Predicate SAM 的方法签名可能是可以推断的:

boolean test(String input)

然而,Java编译器要求为引用提供具体类型,而不仅仅是方法签名。这个要求源于Java对向后兼容性的偏好,正如我之前提到的。通过使用现有的静态类型系统,Lambda表达式完美地适应Java,为Lambda提供了与之前的任何其他类型或方法一样的编译时安全性。

然而,遵守类型系统使得Java的Lambda表达式比其他语言中的Lambda表达式更加静态。仅仅因为两个Lambda表达式共享相同的SAM签名,并不意味着它们是可互换的。

以以下函数式接口为例: interface LikePredicate<T> { boolean test(T value); }

尽管它的SAM与Predicate相同,但这些类型不能互换使用,如下面的代码所示:

LikePredicate<String> isNull = value -> value == null; 

Predicate<String> wontCompile = isNull; 
// Error:
// incompatible types: LikePredicate<java.lang.String> cannot be converted
// to java.util.function.Predicate<java.lang.String>

由于这种不兼容性,你应该尽量依赖java.util.function包中提供的可用接口,这将在第3章中进行讨论,以最大程度地提高互操作性。你仍然会遇到像java.util.concurrent.Callable这样的Java 8之前的接口,它与Java 8+中的java.util.function.Supplier完全相同。如果发生这种情况,有一种巧妙的快捷方式可以将一个Lambda表达式切换到另一个相同类型。你将在“桥接函数式接口”中学到这一点。

作为方法参数和返回类型的临时创建的Lambda表达式不会受到任何类型不兼容性的影响,如下面的示例所示:

List<String> filter1(List<String> values, Predicate<String> predicate) {
  // ...
}

List<String> filter2(List<String> values, LikePredicate<String> predicate) {
  // ...
}

var values = Arrays.asList("a", null, "c");

var result1 = filter1(values, value -> value != null);

var result2 = filter2(values, value -> value != null);

编译器直接从方法签名推断临时创建的Lambda表达式的类型,因此你可以专注于Lambda表达式的实现目标。对于返回类型也是如此:

Predicate<Integer> isGreaterThan(int value) {
  return compareValue -> compareValue > value;
}

现在你已经知道如何创建Lambda表达式了,接下来你需要调用它们。

调用Lambda

Lambda表达式实际上是它们各自函数式接口的具体实现。其他更偏向函数式的语言通常会更动态地处理Lambda表达式。这就是为什么Java的使用模式可能与这些语言不同。

例如,在JavaScript中,你可以直接调用Lambda表达式并传递参数,如下面的代码所示:

let helloWorldJs = name => "hello, " + name + "!"

let resultJs = helloWorldJs("Ben")

然而,在Java中,Lambda表达式的行为类似于接口的任何其他实例,因此你需要显式调用它的SAM,如下所示:

Function<String, String> helloWorld = name -> "hello, " + name + "!";

var result = helloWorld.apply("Ben");

调用单个抽象方法可能不像其他语言那样简洁,但这带来的好处是Java的持续向后兼容性。

方法引用

除了Lambda表达式,Java 8还引入了另一个新特性,通过语言语法的改变,以一种新的方式创建Lambda表达式:方法引用。这是一种简写的语法糖,使用新的::(双冒号)操作符来引用现有方法,而不是从现有方法创建Lambda表达式,从而简化函数式代码。

示例2-8展示了如何通过将Lambda表达式转换为方法引用来改善Stream管道的可读性。不必担心细节!你将在第6章中学习有关Stream的内容;只需要将其视为接受方法的Lambda表达式的流畅调用即可。

List<Customer> customers = ...;

// LAMBDAS
customers.stream()
         .filter(customer -> customer.isActive())
         .map(customer -> customer.getName())
         .map(name -> name.toUpperCase())
         .peek(name -> System.out.println(name))
         .toArray(count -> new String[count]);

// METHOD REFERENCES
customers.stream()
         .filter(Customer::isActive)
         .map(Customer::getName)
         .map(String::toUpperCase)
         .peek(System.out::println)
         .toArray(String[]::new);

使用方法引用替换Lambda表达式可以消除许多“噪声”,而不会影响代码的可读性或可理解性。不需要为输入参数指定实际的名称或类型,也不需要显式调用引用方法。此外,现代IDE通常会提供自动重构功能,以将Lambda表达式转换为方法引用(如果适用)。

根据你想要替换的Lambda表达式以及你需要引用的方法的类型,有四种类型的方法引用可以使用:

  • 静态方法引用

  • 绑定的非静态方法引用

  • 非绑定的非静态方法引用

  • 构造函数引用

让我们来看看不同类型的方法引用以及如何以及何时使用它们。

静态方法引用

静态方法引用指的是特定类型的静态方法,例如在Integer类上可用的toHexString方法:

// EXCERPT FROM java.lang.Integer
public class Integer extends Number {

  public static String toHexString(int i) {
    // ..
  }
}

// LAMBDA
Function<Integer, String> asLambda = i -> Integer.toHexString(i);

// STATIC METHOD REFERENCE
Function<Integer, String> asRef = Integer::toHexString;

静态方法引用的一般语法是ClassName::staticMethodName。

绑定的非静态方法引用

如果你想引用已存在对象的非静态方法,你需要一个绑定的非静态方法引用。Lambda表达式的参数将作为方法参数传递给该特定对象的引用方法:

var now = LocalDate.now();

// LAMBDA BASED ON EXISTING OBJECT
Predicate<LocalDate> isAfterNowAsLambda = date -> $.isAfter(now);

// BOUND NON-STATIC METHOD REFERENCE
Predicate<LocalDate> isAfterNowAsRef = now::isAfter;

你甚至不需要一个中间变量;你可以直接将另一个方法调用的返回值或字段访问与双冒号(::)操作符结合使用:

// BIND RETURN VALUE
Predicate<LocalDate> isAfterNowAsRef = LocalDate.now()::isAfter;

// BIND STATIC FIELD
Function<Object, String> castToStr = String.class::cast;

你还可以使用this::引用当前实例的方法,或使用super::引用父类的方法,具体如下所示:

public class SuperClass {

  public String doWork(String input) {
    return "super: " + input;
  }
}

public class SubClass extends SuperClass {

  @Override
  public String doWork(String input){
    return "this: " + input;
  }

  public void superAndThis(String input) {
    Function<String, String> thisWorker = this::doWork;
    var thisResult = thisWorker.apply(input);
    System.out.println(thisResult);

    Function<String, String> superWorker = SubClass.super::doWork;
    var superResult = superWorker.apply(input);
    System.out.println(superResult);
  }
}

new SubClass().superAndThis("hello, World!");

// OUTPUT:
// this: hello, World!
// super: hello, World!

绑定的方法引用是一种很好的方式,可以在变量、当前实例或父类上使用已存在的方法。它还允许你将复杂的Lambda表达式重构为方法,并使用方法引用代替。特别是在流式处理(如第6章中的流(Streams)或第9章中的Optional)中,简短的方法引用极大地提高了可读性。

绑定的非静态方法引用的一般语法遵循以下模式:

objectName::instanceMethodNameobjectName::instanceMethodNameobjectName::instanceMethodName

非绑定的非静态方法

正如其名称所示,未绑定的非静态方法引用并不绑定到特定的对象。相反,它们引用了一个类型的实例方法:

// EXCERPT FROM java.lang.String
public class String implements ... {

  public String toLowerCase() {
    // ...
  }
}

// LAMBDA
Function<String, String> toLowerCaseLambda = str -> str.toLowerCase();

// UNBOUND NON-STATIC METHOD REFERENCE
Function<String, String> toLowerCaseRef = String::toLowerCase;

未绑定的非静态方法引用的一般语法遵循以下模式:

ClassName::instanceMethodNameClassName::instanceMethodNameClassName::instanceMethodName

这种类型的方法引用可能会与静态方法引用混淆。然而,对于未绑定的非静态方法引用,ClassName表示定义了被引用的实例方法的实例类型。它也是lambda表达式的第一个参数。通过这种方式,引用方法在传入的实例上被调用,而不是在显式引用的该类型的实例上调用。

构造器引用

最后一种类型的方法引用是指向类型的构造函数的引用。构造函数方法引用的样子如下所示:

// LAMBDA
Function<String, Locale> newLocaleLambda = language -> new Locale(language);

// CONSTRUCTOR REFERENCE
Function<String, Locale> newLocaleRef = Locale::new;

乍看之下,构造函数方法引用看起来像是静态方法引用或未绑定的非静态方法引用。被引用的方法并不是一个实际的方法,而是通过关键字new引用一个构造函数。

构造函数方法引用的一般语法是ClassName::new

Java中的函数式编程概念

第一章从主要的理论角度探讨了使编程语言具有函数式特性的核心概念。现在让我们从Java开发者的角度再次审视它们。

纯函数和引用透明度

纯函数的概念基于两个保证,这些保证并不一定与函数式编程绑定:

  1. 函数逻辑是自包含的,没有任何副作用。
  2. 相同的输入始终会产生相同的输出。因此,可以用初始结果替代重复调用,使调用具有引用透明性。

这两个原则即使在命令式代码中也是有意义的。使代码自包含使其可预测且更简单。从Java的角度来看,如何实现这些有益的特性呢? 首先,检查是否存在不确定性。是否存在不依赖于输入参数的不可预测逻辑?典型的例子包括随机数生成器或当前日期。在函数中使用这些数据会使函数失去可预测性,从而使其不纯。

接下来,查找副作用和可变状态:

  • 你的函数是否会影响函数本身以外的状态,例如实例变量或全局变量?
  • 它是否会改变其参数的内部数据,例如向集合中添加新元素或更改对象属性?
  • 它是否执行其他不纯的操作,例如I/O操作?

然而,副作用并不限于可变状态。例如,一个简单的System.out.println调用就是一个副作用,即使它看起来可能无害。任何形式的I/O,例如访问文件系统或进行网络请求,都是副作用。原因很简单:相同参数的重复调用无法用第一次计算的结果替代。一个纯粹方法的一个很好的指标是它的返回类型为void。如果一个方法没有返回任何内容,它要么只执行副作用,要么什么都不做。

纯函数本质上是引用透明的。因此,你可以用之前计算的结果替代任何使用相同参数的后续调用。这种可互换性允许一种名为记忆化(memoization)的优化技术。记忆化源自拉丁词“memorandum”,意为“记住”,它描述了“记住”之前计算过的表达式的技术。它以内存空间为代价来节省计算时间。

你很可能已经在你的代码中使用了引用透明的一般思想,以缓存的形式存在。从专用的缓存库(如Ehcache6)到基于HashMap的简单查找表,都是为了在一组输入参数中“记住”一个值。

Java编译器不支持对Lambda表达式或方法调用的自动记忆化。一些框架提供了注解,如Spring中的@Cacheable7或Apache Tapestry中的@Cached8,并在幕后自动生成所需的代码。

通过创建自己的Lambda表达式缓存也并不难,这要归功于Java 8+引入的一些新功能。所以我们现在就来做。

通过创建一个“按需”查找表来构建自己的记忆化,需要回答两个问题:

  1. 如何唯一标识函数和其输入参数?
  2. 如何存储计算结果?

如果你的函数或方法调用只有一个带有常量hashCode或其他确定性值的参数,你可以创建一个简单的基于Map的查找表。对于多参数调用,你必须首先定义如何创建查找键。

Java 8引入了对Map类型的多个函数式添加。其中之一是computeIfAbsent方法,它是实现记忆化的很好的助手,如示例2-9所示:

Map<String, Object> cache = new HashMap<>(); 

<T> T memoize(String identifier, Supplier<T> fn) { 
  return (T) cache.computeIfAbsent(identifier, key -> fn.get());
}

Integer expensiveCall(String arg0, int arg1) { 
    // ...
}

Integer memoizedCall(String arg0, int arg1) { 
  var compoundKey = String.format("expensiveCall:%s-%d", arg0, arg1);
  return memoize(compoundKey, () -> expensiveCall(arg0, arg1));
}

var calculated = memoizedCall("hello, world!", 42); 

var cached = memoizedCall("hello, world!", 42);

这个实现相当简单,并不是一个通用的解决方案。然而,它传达了通过一个实际进行记忆化的中间方法来存储调用结果的一般概念。

对Map的函数式添加并不止于此。它提供了创建“即时”关联的工具,以及更多的工具,让你更精细地控制一个值是否已经存在。你将在第11章中学到更多相关内容。

不可变性

传统的Java与面向对象编程的方法是基于可变的程序状态,最突出的代表是JavaBeans和POJOs,这也在第4章中讨论过。关于如何处理面向对象编程中的程序状态并没有明确的定义,不可变性也不是函数式编程的先决条件或独特特征。然而,可变状态对于许多函数式编程概念来说是个问题,因为它们期望使用不可变数据结构来确保数据完整性和整体安全性。

与其他语言相比,Java对不可变性的支持相对有限。这就是为什么它必须强制使用像"effectively final"(在"Lambda表达式和外部变量"中讨论过)这样的结构。要支持"完全"的不可变性,你需要从头开始设计你的数据结构为不可变的,这可能会很繁琐且容易出错。第三方库通常是一种选择,可以最大限度地减少所需的样板代码,并依赖于经过验证的实现。最后,在Java 14+中引入了不可变数据类(Records)来填补这个空白,这将在第5章中进行讨论。

不可变性是一个复杂的主题,你将在第4章中学到更多相关知识,包括其重要性以及如何正确地利用它——无论是使用内置工具还是采用自己动手的方法。

第一类公民权

由于Java lambda表达式是函数式接口的具体实现,它们获得了第一类公民权,并可以作为变量、参数和返回值使用,就像示例2-10中所示:

// VARIABLE ASSIGNMENT

UnaryOperator<Integer> quadraticFn = x -> x * x; 
quadraticFn.apply(5); 
// => 25

// METHOD ARGUMENT

public Integer apply(Integer input, UnaryOperator<Integer> operation) {
  return operation.apply(input); 
}

// RETURN VALUE

public UnaryOperator<Integer> multiplyWith(Integer multiplier) {
  return x -> multiplier * x; 
}

UnaryOperator<Integer> multiplyWithFive = multiplyWith(5);
multiplyWithFive.apply(6);
// => 30

接受lambda作为参数并返回lambda对于下一个概念,即函数组合,是必不可少的。

函数组合

通过组合较小的组件来创建复杂系统的理念是编程的基石,无论选择哪种范式进行编程。在面向对象编程中,对象可以由较小的对象组合而成,构建一个更复杂的API。在函数式编程中,两个函数被组合成一个新函数,然后可以进一步组合。

函数组合可以说是函数式编程思维中至关重要的一个方面。它允许您通过将较小、可重用的函数组合成一个更大的链式结构来构建复杂系统,完成更复杂的任务,如图2-1所示。

《Java的函数式》第二章:函数式Java

Java的函数组合能力在很大程度上取决于所涉及的具体类型。在《函数组合》中,我将讨论如何组合JDK提供的不同函数接口。

惰性求值

尽管Java在原则上是一种非惰性(strict或eager)语言,但它支持多个惰性构造:

  • 逻辑短路运算符
  • if-else和:?(三元)运算符
  • for循环和while循环

逻辑短路运算符是惰性的一个简单示例:

var result1 = simple() && complex();

var result2 = simple() || complex();

复杂方法的评估取决于简单类的结果以及在整个表达式中使用的逻辑运算符。这就是为什么JVM可以丢弃不需要评估的表达式的原因,这将在第11章中详细解释。