likes
comments
collection
share

《Java的函数式》第一章:函数式编程介绍

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

第一章:函数式编程介绍

为了更好地理解如何在Java中融入更多的函数式编程风格,首先需要了解什么是函数式编程,以及它的基本概念是什么。 本章将探讨函数式编程的基础知识,以便将更多的函数式编程风格融入到你的工作流程中。

什么使一种语言成为函数式编程语言?

编程范式,如面向对象、函数式或过程式,是综合性的概念,用于对语言进行分类,并提供了以特定风格结构化程序和使用不同方法解决问题的方式。与大多数范式一样,函数式编程没有一个被广泛接受的单一定义,人们对何为真正的函数式语言有着不同的争论。我不打算给出自己的定义,而是会介绍一些使语言成为函数式的不同方面。

一种语言被认为是函数式的,当有一种方式可以通过创建和组合抽象函数来表示计算。这个概念源于数理逻辑学家阿隆佐·邱奇在20世纪30年代发明的形式数学系统——λ演算。λ演算是一种用于表达通过抽象函数进行计算以及如何将变量应用于函数的系统。其名称“λ演算”来自于希腊字母“lambda”,它被选择为其符号:λ。

作为一个面向对象的开发者,你习惯于命令式编程:通过定义一系列语句,告诉计算机如何使用一系列语句来完成特定任务。

要使一种编程语言被认为是函数式的,需要能够使用声明式的方式表达计算逻辑,而不需要描述其实际的控制流程。在这种声明式的编程风格中,你使用表达式描述结果和程序的工作方式,而不是使用语句描述程序应该做什么。

在Java中,表达式是由运算符、操作数和方法调用组成的序列,它定义了一个计算并求值为单个值的过程:

x * x
2 * Math.PI * radius
value == null ? true : false

另一方面,语句是由代码执行的操作组成的完整执行单元,包括没有返回值的方法调用。当你给变量赋值或更改变量的值、调用无返回值的方法,或者使用诸如if-else这样的控制流结构时,你正在使用语句。通常,它们与表达式交织在一起:

int totalTreasure = 0; 

int newTreasuresFound = findTreasure(6); 

totalTreasure = totalTreasure + newTreasuresFound; 

if (totalTreasure > 10) { 
  System.out.println("You have a lot of treasure!"); 
} else {
  System.out.println("You should look for more treasure!"); 
}

函数式编程的概念

由于函数式编程主要基于抽象函数,它的许多概念形成了以声明式风格解决问题的范式,与命令式的“如何解决”方法形成对比。 我们将介绍函数式编程在其基础上使用的最常见和重要的概念。尽管这些概念不仅适用于函数式范式,但它们背后的许多思想也适用于其他编程范式。

纯函数和引用透明性

函数式编程将函数分为两类:纯函数和非纯函数。

纯函数具有两个基本保证:

  1. 相同的输入始终会产生相同的输出。
  2. 纯函数的返回值只能取决于其输入参数。

纯函数是自包含的,不会产生任何副作用。 代码不能影响全局状态,比如改变参数值或进行任何输入/输出操作。

这两个保证使得纯函数可以在任何环境中安全使用,甚至可以以并行的方式进行操作。以下代码展示了一个纯函数的例子,它接受一个参数而不会影响其上下文之外的任何内容:

public String toLowercase(String str) {
  return str;
}

违反上述两个保证之一的函数被视为非纯函数。下面的代码是一个非纯函数的示例,因为它在逻辑中使用了当前时间:

public String buildGreeting(String name) {
  var now = LocalTime.now();
  if (now.getHour() < 12) {
    return "Good morning " + name;
  } else {
    return "Hello " + name;
  }
}

“纯函数”和“非纯函数”这两个词的意义可能会引起不良联想,这是相当不幸的命名。一般来说,非纯函数并不比纯函数差。它们只是根据您想要遵循的编码风格和范式以不同的方式使用。

无副作用的表达式或纯函数的另一个特点是它们的确定性,这使它们成为具有引用透明性的函数。这意味着您可以将它们替换为它们的计算结果,而不会改变程序的行为,以便进行任何进一步的调用。

函数: f(x)=x∗xf(x) = x * xf(x)=xx

替换已计算的表达式: result=f(5)+f(5)=25+f(5)=25+25result = f(5) + f(5) = 25 + f(5) = 25 + 25result=f(5)+f(5)=25+f(5)=25+25

所有这些变体是相等的,并不会改变您的程序。纯度和引用透明性相辅相成,为您提供了一个强大的工具,因为它使得代码更易于理解和推理。

不变性(Immutability)

面向对象的代码通常基于可变的程序状态。对象通常在创建后会发生变化,使用setter方法进行修改。但是,改变数据结构可能会产生意外的副作用。然而,可变性不仅限于数据结构和面向对象编程。方法中的局部变量也可能是可变的,并且可能会导致与对象的字段变化一样的问题。

通过不变性,数据结构在初始化后就不能再发生变化。由于数据结构不变,它们始终保持一致、无副作用、可预测且更易于理解。与纯函数一样,它们在并发和并行环境中的使用是安全的,无需担心未同步的访问或超出作用域的状态变化的问题。

如果数据结构在初始化后永远不会变化,那么程序将变得无用。这就是为什么需要创建一个包含修改状态的新的更新版本,而不是直接修改数据结构。

每次进行更改时创建新的数据结构可能很麻烦且效率低下,因为需要复制数据。许多编程语言采用“结构共享”(structure sharing)来提供高效的复制机制,以最小化需要为每次更改创建新的数据结构所带来的低效性。通过这种方式,不同的数据结构实例之间共享不可变数据。第四章将更详细地解释为什么拥有无副作用的数据结构的优势超过可能需要的额外工作。

递归

递归是一种问题解决技术,通过部分解决相同形式的问题,并将部分结果组合起来最终解决原始问题。简单来说,递归函数通过调用自身,但在输入参数上稍作修改,直到达到终止条件并返回实际值。第12章将详细介绍递归的细节。

一个简单的例子是计算阶乘,即小于或等于输入参数的所有正整数的乘积。函数不使用中间状态来计算值,而是通过使用递减的输入变量来调用自身,如图1-1所示。

《Java的函数式》第一章:函数式编程介绍

纯函数式编程通常更喜欢使用递归而不是循环或迭代器。其中一些语言,如Haskell,更进一步,根本没有像for或while这样的循环结构。 由于重复的函数调用可能效率低下甚至危险,可能导致栈溢出的风险。因此,许多函数式语言利用优化技术,如将递归“展开”为循环或尾调用优化,以减少所需的堆栈帧。Java并不支持这些优化技术,关于这些技术我将在第12章中更详细地进行讨论。

一等函数和高阶函数

在代码中支持更函数式编程风格并不一定需要将之前讨论的许多概念作为深度集成的语言特性。然而,第一等函数和高阶函数的概念是绝对必不可少的。 为了使函数成为所谓的“第一等公民”,它们必须具备语言中其他实体固有的所有特性。它们需要能够被分配给变量,并在其他函数和表达式中用作参数和返回值。

高阶函数利用这种第一等公民身份来接受函数作为参数,或者将函数作为结果返回,或者两者兼而有之。这是下一个概念——函数组合的重要特性。

函数组合

纯函数可以组合在一起创建更复杂的表达式。从数学角度来看,这意味着两个函数 f(x)和g(y)f(x)和g(y)f(x)g(y)可以组合成h(x)=g(f(x))h(x)=g(f(x))h(x)=g(f(x)),如图:

《Java的函数式》第一章:函数式编程介绍

这样,函数可以尽可能小而精确,因此更容易复用。为了创建更复杂和完整的任务,这样的函数可以根据需要快速组合起来。

柯里化(Currying)

函数柯里化是指将一个接受多个参数的函数转换为一系列只接受单个参数的函数的过程。

想象一个接受三个参数的函数。它可以通过以下方式进行柯里化: 初始函数:x=f(a,b,c) x = f(a, b, c)x=f(a,b,c)

柯里化后的函数: h=g(a)i=h(b)x=i(c)h = g(a) i = h(b) x = i(c)h=g(a)i=h(b)x=i(c)

柯里化函数的序列: x=g(a)(b)(c)x = g(a)(b)(c)x=g(a)(b)(c)

一些函数式编程语言在其类型定义中反映了柯里化的一般概念,例如Haskell的类型定义如下:

add :: Integer -> Integer -> Integer 
add x y =  x + y

乍一看,对于面向对象或命令式开发者来说,这个概念可能会感到奇怪和陌生,就像许多基于数学的原理一样。然而,它完美地传达了一个具有多个参数的函数可以表示为函数的概念,这是支持下一个概念的重要认识。

部分函数应用(Partial Function Application)

部分函数应用是通过仅提供现有函数所需参数的子集来创建新函数的过程。它经常与柯里化混淆,但部分应用的函数调用返回结果,而不是另一个柯里化链的函数。

前面部分讲解的柯里化示例可以进行部分应用,从而创建一个更具体的函数:

add :: Integer -> Integer -> Integer 
add x y =  x + y

add3 = add 3 

add3 5

通过部分应用,您可以即时创建新的更简洁的函数,或者从更通用的函数集中创建专门的函数,以匹配您代码的当前上下文和要求。

延迟求值(Lazy Evaluation)

惰性求值是一种评估策略,它延迟对表达式的评估,直到其结果确实被需要,通过将创建表达式的方式与实际使用它的方式或时间分开来解决相关问题。这个概念并非根源于函数式编程,也不受其限制,但它对于使用其他函数式概念和技术来说是必不可少的。

许多非函数式语言,包括Java,在主要上是严格或急切评估的,这意味着表达式会立即进行评估。这些语言仍然具有一些惰性构造,比如控制流语句如if-else语句或循环,或者逻辑短路运算符。立即评估if-else结构的两个分支或所有可能的循环迭代并没有太多意义,不是吗?

因此,只有在运行时绝对需要的分支和迭代会被评估。 惰性使得某些在其他情况下无法实现的构造成为可能,比如无限数据结构或某些算法的更高效实现。它也与引用透明性非常契合。如果一个表达式和其结果没有区别,你可以延迟评估而不会影响结果。

延迟评估可能会影响程序的性能,因为你可能不知道评估的确切时间。 在第11章中,我将讨论如何在Java中使用现有工具实现惰性方法,并介绍如何创建自己的工具。

函数式编程的优势

经过对函数式编程最常见和基本概念的了解后,您可以看到它们如何反映在更函数式方法的优势中:

简洁性

没有可变状态和副作用,您的函数倾向于更小,只做“它们应该做的事情”。

一致性

不可变的数据结构可靠且一致。不再担心意外或意料之外的程序状态。

(数学)正确性

简单的代码和一致的数据结构将自动导致具有较小错误表面的“更正确”的代码。您的代码越“纯净”,推理和调试就越容易,从而简化了调试和测试过程。

更安全的并发性

在“传统”Java中,并发是最具挑战性的任务之一。函数式概念允许您消除许多问题,以相对较低的代价获得更安全的并行处理。

模块化

小而独立的函数导致更简单的可重用性和模块化。结合函数组合和部分应用,您可以轻松地使用这些较小的部分构建更复杂的任务。

可测试性

许多函数式概念,如纯函数、引用透明性、不可变性和关注点分离,使得测试和验证更加容易。

函数式编程的缺陷

虽然函数式编程有许多优点,但了解可能的陷阱也是至关重要的。

学习曲线

函数式编程基于的高级数学术语和概念可能相当令人生畏。尽管如此,要扩充您的Java代码,您绝对不需要知道“单子只是范畴论中的自函子的幺半群”。然而,您会遇到新的、通常不熟悉的术语和概念。

更高的抽象层级

面向对象编程使用对象来建模抽象,而函数式编程使用更高的抽象层级来表示数据结构,使它们非常优雅,但往往更难以识别。

处理状态

处理状态并非易事,无论选择的编程范式如何。尽管函数式编程的不可变性方法消除了许多可能的错误表面,但如果数据结构实际上需要变化,尤其是如果您习惯于在面向对象代码中使用setter方法,那么更难以对其进行修改。

性能影响

函数式编程在并发环境中使用起来更简单、更安全。然而,这并不意味着它与其他范式相比天生更快,特别是在单线程环境中。尽管函数式编程有许多好处,但许多函数式技术(如不可变性或递归)可能会受到所需的开销影响。这就是为什么许多函数式编程语言利用各种优化手段来减轻这些问题,比如最小化复制的专用数据结构,或者对递归等技术进行编译器优化。

问题背景的最佳选择

并非所有问题背景都适合使用函数式方法。像高性能计算、I/O密集型问题或低级系统和嵌入式控制器这样需要对数据局部性和显式内存管理等细粒度控制的领域与函数式编程不太搭配。

作为程序员,我们必须在任何范式和编程方法的优点和缺点之间寻找平衡。这本书向您展示了如何选择Java函数式演进的最佳部分,并利用它们来增强您的面向对象Java代码。

总结

  1. 函数式编程建立在lambda演算的数学原理之上。
  2. 基于表达式而非语句的声明式编程风格对函数式编程至关重要。
  3. 许多编程概念在本质上具有函数式的特点,但并非将语言或代码变得“函数式”所必需。即使是非函数式的代码也能从函数式编程的基本思想和整体思维方式中受益。
  4. 纯度、一致性和简洁性是应用于代码中的重要属性,以充分发挥函数式编程方法的优势。
  5. 在函数式概念和它们在实际应用中之间可能需要权衡。尽管有缺点,但它们的优点通常超过缺点,或者至少可以以某种形式进行缓解。