Kumar Chandrakant 2025-03-26
1. 概述
在本教程中,我们将了解函数式编程范式的核心原则,以及如何在 Java 编程语言中实践这些原则。
我们还将涵盖一些高级的函数式编程技术。
这将帮助我们评估函数式编程(尤其是在 Java 中)所带来的好处。
2. 什么是函数式编程?
简而言之,函数式编程是一种编写计算机程序的风格,它将计算视为对数学函数的求值。
在数学中,函数是一种将输入集合与输出集合关联起来的表达式。
重点:函数的输出仅依赖于其输入。更有趣的是,我们可以将两个或多个函数组合在一起,从而得到一个新函数。
2.1. λ 演算(Lambda Calculus)
要理解为什么数学函数的这些定义和属性在编程中如此重要,我们需要稍微回溯一下历史。
在 1930 年代,数学家阿隆佐·邱奇(Alonzo Church)开发了一种基于函数抽象的计算形式系统。这个通用的计算模型后来被称为 λ 演算(lambda calculus)。
λ 演算对编程语言理论的发展产生了巨大影响,尤其是函数式编程语言。通常,函数式编程语言都实现了 λ 演算。
由于 λ 演算关注函数组合,因此函数式编程语言提供了富有表现力的方式来通过函数组合构建软件。
2.2. 编程范式的分类
当然,函数式编程并不是唯一被广泛使用的编程风格。广义上讲,编程风格可分为 命令式(imperative)和 声明式(declarative)两大范式。
命令式方法 将程序定义为一系列改变程序状态的语句,直到达到最终状态。
- 过程式编程 是命令式编程的一种,其中我们使用过程或子例程来构建程序。
- 广为人知的 面向对象编程(OOP)则扩展了过程式编程的概念。
声明式方法 则在不描述控制流(即语句序列)的情况下表达计算逻辑。
- 简单来说,声明式方法关注的是“程序要实现什么”,而不是“如何实现”。
- 函数式编程是声明式编程语言的一个子集。
这些类别还有更细的子分类,其分类体系相当复杂,但本教程不会深入探讨。
2.3. 编程语言的分类
现在,我们尝试从对函数式编程的支持程度来理解编程语言的划分。
- 纯函数式语言(如 Haskell)只允许编写纯函数式程序。
- 其他语言同时支持函数式和过程式编程,被称为 非纯函数式语言。许多语言属于这一类,包括 Scala、Kotlin 和 Java。
需要强调的是,如今大多数流行的编程语言都是通用语言,因此往往支持多种编程范式。
3. 基本原则与核心概念
本节将介绍函数式编程的一些基本原则,以及如何在 Java 中应用它们。
请注意,我们即将使用的许多特性并非 Java 一直具备的。建议使用 Java 8 或更高版本,以有效实践函数式编程。
3.1. 一等函数与高阶函数
如果一门编程语言将函数视为 一等公民(first-class citizens),我们就说它具有 一等函数(first-class functions)。
这意味着函数可以支持其他实体通常能进行的所有操作,包括:
- 将函数赋值给变量
- 将函数作为参数传递给其他函数
- 从函数中返回函数
这一特性使得在函数式编程中定义 高阶函数(higher-order functions)成为可能。高阶函数能够接收函数作为参数,并/或返回一个函数作为结果。这进一步支持了函数组合、柯里化等函数式编程技术。
在 Java 8 之前,传递函数只能通过 函数式接口(functional interfaces)或匿名内部类实现。函数式接口恰好只有一个抽象方法,也称为 SAM 接口(Single Abstract Method)。
例如,如果我们想为 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 中真正的“一等公民”是 对象(Object),而非函数。
3.2. 纯函数
纯函数的定义强调:函数的返回值仅由其输入参数决定,且没有副作用(side effects)。
这听起来似乎与 Java 的最佳实践相悖。
作为一门面向对象语言,Java 推崇 封装(encapsulation)——隐藏对象内部状态,仅通过必要方法访问或修改。因此,这些方法通常不是严格意义上的纯函数。
当然,封装等面向对象原则只是建议,并非强制。
事实上,开发者近年来逐渐意识到:定义不可变状态和无副作用的方法具有重要价值。
例如,假设我们要计算刚排序后的数字总和:
Integer sum(List<Integer> numbers) {
return numbers.stream().collect(Collectors.summingInt(Integer::intValue));
}
该方法仅依赖于传入的参数,因此是确定性的;同时,它不产生任何副作用。
副作用 是指方法预期行为之外的任何操作,例如:
- 修改局部或全局状态
- 向数据库写入数据
- (严格意义上)甚至日志记录也被视为副作用
那么,如何处理合法的副作用呢?例如,我们可能确实需要将结果保存到数据库。函数式编程提供了一些技术,在保留纯函数的同时处理副作用——我们将在后续章节讨论。
3.3. 不可变性(Immutability)
不可变性是函数式编程的核心原则之一,指 实体一旦创建就无法被修改。
在函数式语言中,这一特性由语言本身在设计层面支持。但在 Java 中,我们需要主动选择创建不可变的数据结构。
注意:Java 本身已提供多种内置不可变类型,例如
String(出于安全考虑,因String广泛用于类加载和哈希键)、基本类型的包装类(如Integer)以及数学相关类型。
那么,我们自己创建的数据结构呢?它们默认不是不可变的,需要我们采取措施实现不可变性。
使用 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;
}
}
需严格遵守以下规则:
- 所有字段必须是不可变的;
- 此规则也适用于所有嵌套类型和集合(包括其内容);
- 应提供一个或多个构造函数用于初始化;
- 只应提供无副作用的访问器方法(getter)。
随着数据结构变得复杂,完全正确地实现不可变性并不容易。
不过,有多个外部库可简化 Java 中的不可变数据操作,例如 Immutables 和 Project Lombok,它们提供了现成的框架来定义不可变数据结构。
3.4. 指称透明性(Referential Transparency)
指称透明性可能是函数式编程中最难理解的原则之一,但其概念其实很简单。
如果一个表达式可以被其对应的值替换,而 不影响程序行为,那么该表达式就是 指称透明的。
这使得函数式编程能够支持高阶函数、惰性求值等强大技术。
举个例子:
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");
这三个日志调用在语义上等价,但 不具备指称透明性:
- 第一个调用因产生日志副作用而不具备指称透明性。若用其值(如第三个调用)替换,会丢失日志。
- 第二个调用也不具备指称透明性,因为
SimpleData是可变的。程序中任意位置调用data.setData(...)都会使替换失效。
因此,要实现指称透明性,我们的函数必须是 纯函数,数据必须是 不可变的——这正是前文提到的两个前提条件。
指称透明性的一个有趣结果是:我们能写出 上下文无关的代码。换言之,代码可在任意顺序和上下文中运行,从而带来多种优化可能。
4. 函数式编程技术
前述原则使我们能够运用多种技术来获益于函数式编程。
本节将介绍一些流行技术,并展示如何在 Java 中实现。
4.1. 函数组合(Function Composition)
函数组合指通过组合简单函数来构建复杂函数。
在 Java 中,这主要通过 函数式接口 实现——它们是 Lambda 表达式和方法引用的目标类型。
任何只有一个抽象方法的接口都可作为函数式接口。Java 8 在 java.util.function 包中提供了大量预定义的函数式接口。
许多接口通过默认方法和静态方法支持函数组合。以 Function 接口为例:
Function<T, R> 是一个接受一个参数并产生结果的通用函数式接口,它提供了两个默认方法:compose 和 andThen。
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))); // 输出: 1.06
Function<Double, Double> sqrtThenLog = sqrt.andThen(log);
logger.log(Level.INFO, String.valueOf(sqrtThenLog.apply(3.14))); // 输出: 0.57
compose:先执行传入的函数,再执行当前函数;andThen:先执行当前函数,再执行传入的函数。
其他函数式接口也提供有趣的组合方法,例如 Predicate 中的 and、or、negate。此外,还有双参数版本如 BiFunction 和 BiPredicate。
4.2. 单子(Monads)
许多函数式编程概念源于 范畴论(Category Theory)——数学中关于函数的一般理论,包含函子(functors)、自然变换等概念。
对我们而言,只需知道这是 单子(Monad)的基础。
形式上,单子是一种抽象,允许以通用方式构造程序。
它允许我们包装一个值,应用一系列变换,并最终取出已应用所有变换的值。
单子需满足三条定律:左单位律、右单位律和结合律(此处不展开)。
在 Java 中,我们常使用的单子包括 Optional 和 Stream:
Optional.of(2).flatMap(f -> Optional.of(3).flatMap(s -> Optional.of(f + s)))
为何称 Optional 为单子?
- 它通过
of方法包装值; - 通过
flatMap应用变换(如将另一个包装值相加)。
虽然 Optional 在某些情况下不完全满足单子定律,但在大多数实际场景中足够可用。
类似地,Stream 和 CompletableFuture 也是 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 表达式 和 闭包(closures)。
Lambda 表达式是匿名函数,使代码可作为数据处理(通过函数式接口实现)。
Lambda 表达式可捕获其词法作用域中的变量,这就是 闭包。
例如:
private static Function<Double, Double> weightOnEarth() {
final double gravity = 9.81;
return mass -> mass * gravity;
}
注意:Lambda 表达式依赖于外层变量 gravity(即闭包)。
但 Java 限制:被捕获的变量必须是 final 或 effectively final(事实上的 final)。
有趣的是,柯里化还允许我们在 Java 中创建任意元数(arity)的函数式接口。
4.4. 递归(Recursion)
递归是函数式编程中的另一项强大技术,可将问题分解为更小的部分。其主要优势在于 避免命令式循环中常见的副作用。
例如,用递归计算阶乘:
Integer factorial(Integer number) {
return (number == 1) ? 1 : number * factorial(number - 1);
}
此函数递归调用自身,直到达到基准情况(base case),然后逐层返回结果。
注意:我们在计算前进行递归调用(即在计算“头部”进行递归),这种风格称为 头递归(head recursion)。
缺点:每一步都需保存之前所有步骤的状态。对小数值无妨,但大数值时效率低下。
解决方案是 尾递归(tail recursion):确保递归调用是函数的最后一个操作。
改写为尾递归:
Integer factorial(Integer number, Integer result) {
return (number == 1) ? result : factorial(number - 1, result * number);
}
这里引入了 累加器(accumulator),避免每步保存状态。其真正优势在于支持 尾调用优化(tail-call elimination)——编译器可丢弃当前函数的栈帧。
虽然 Scala 等语言支持尾调用优化,但 Java 目前尚不支持。
这已被列入 Java 的待办事项,未来可能通过 Project Loom 等大型改进实现。
5. 为何函数式编程重要?
至此,你可能会问:为何要付出如此多努力?对 Java 开发者而言,转向函数式编程并非易事,必然有其显著优势。
最大优势:纯函数 + 不可变状态。
回顾过往,大多数编程难题都源于副作用和可变状态。消除它们可使程序更易阅读、推理、测试和维护。
声明式编程能写出非常简洁、可读的代码。作为其子集,函数式编程提供了高阶函数、函数组合、函数链等构造。想想 Java 8 的 Stream API 为数据处理带来的便利!
但请勿盲目切换。函数式编程 不是 一个可立即套用并受益的设计模式。
它更是一种 思维方式的转变:
如何思考问题及其解决方案,如何构建算法。
因此,在使用函数式编程前,我们必须训练自己 用函数来思考程序。
6. Java 是否适合函数式编程?
函数式编程的好处毋庸置疑,但 Java 是否适合?
历史上,Java 是为面向对象编程设计的通用语言。在 Java 8 之前,几乎无法想象使用函数式编程!但 Java 8 之后情况大为改观。
然而,Java 仍存在根本性限制:
- 没有真正的函数类型,违背函数式编程基本原则(Lambda 表达式只是函数式接口的语法糖);
- 类型默认可变,创建不可变类型需大量样板代码;
- 默认采用及早求值(eager evaluation),而函数式编程推荐 惰性求值(lazy evaluation)——虽可通过短路操作符和函数式接口模拟,但较繁琐;
- 其他缺失:泛型类型擦除、缺乏尾调用优化等。
结论:
- 若从零开始新项目,Java 并非函数式编程的理想选择;
- 但若已有 Java(尤其是 OOP)项目,完全可以结合函数式编程的优势(尤其在 Java 8+)。
对 Java 开发者而言,函数式编程的最大价值在于:将面向对象与函数式编程结合,取长补短。
7. 结论
本文介绍了函数式编程的基础知识,涵盖其核心原则及在 Java 中的实践方法。
我们还通过 Java 示例讨论了若干流行函数式编程技术。
最后,我们分析了采用函数式编程的优势,并回答了“Java 是否适合函数式编程”这一问题。
函数式编程不是银弹,但在合适场景下,它能显著提升代码质量与开发效率。