环境:Java21
1. 简介
函数式编程是一种编程范式,以函数为核心,避免改变状态与可变数据,强调函数的第一公民地位。它通过使用高阶函数和纯函数,实现代码的模块化和重用性,提升可读性和可维护性,常用于并发编程和数学计算等领域。
在函数式编程中,有两条非常重要的规则:
- 无数据变异
这意味着一旦数据对象被创建后就不应该再被更改。任何对该对象的操作都应该返回一个新的对象,而不是修改原始对象。 - 无隐式状态
应避免隐藏或隐式状态。这样理解:在传统编程中,一个函数可能依赖于一些外部或隐藏的状态,比如全局变量、静态变量或者类成员变量等,这些状态不是通过参数传递给函数的。在函数式编程中,提倡避免这种隐式的依赖关系,而是将所有需要的状态都作为参数显式地传递给函数。这样做的结果是提高了代码的透明度和可测试性,因为你清楚地知道函数依赖哪些输入来产生输出,同时也减少了副作用的发生,即函数执行时除了返回值外不改变其他任何东西。
除了上述内容外,还有以下可以在Java中应用的函数式编程概念:
- 高阶函数(Higher-order functions)
- 闭包(Closures)
- 柯里化(Currying)
- 递归(Recursion)
- 惰性求值(Lazy evaluations)
- 引用透明性(Referential transparency)
使用函数式编程并不意味着必须全盘采用,你可以始终使用函数式编程概念来补充面向对象的概念,尤其是在Java中。无论你使用的范式或语言是什么,都可以尽可能地利用函数式编程的优点。
接下来,我们将详细介绍函数式编程在Java中的应用
2. 实战案例
2.1 一等函数和高阶函数
在一等函数的上下文中,函数被视为头等公民,意味着它们可以被赋值给变量、作为参数传递给其他函数、从函数中返回,以及包含在数据结构中。遗憾的是,Java并不完全支持这一特性,因此像闭包、柯里化和高阶函数这样的概念在Java中实现起来不如在其他语言中那么方便。
在Java中最接近一等函数的概念是Lambda表达式。此外,在java.util.function包下还有一些内置的函数式接口,如Function、Consumer、Predicate、Supplier等,可以用于函数式编程。
只有当一个函数接受一个或多个函数作为参数,或者返回另一个函数作为结果时,它才能被视为高阶函数。在Java中,我们最接近高阶函数的方式是使用Lambda表达式和内置的函数式接口。
public class Test {
public static void main(String[] args) {
var list = Arrays.asList("Orange", "Apple", "Banana", "Grape", "XPack", "AKF");
var ret = calcLength(list, new FnFactory<String, Object>() {
public Object execute(final String it) {
return it.length();
}
});
System.err.printf("Length: %s%n", ret);
}
static <T, S> ArrayList<S> calcLength(List<T> arr, FnFactory<T, S> fn) {
var list = new ArrayList<S>();
arr.forEach(t -> list.add(fn.execute(t)));
return list;
}
@FunctionalInterface
public interface FnFactory<T, S> {
S execute(T it);
}
输出结果:
Length: [6, 5, 6, 5, 5, 3]
接下来,我们使用内置的Function接口和Lambda表达式语法来简化上面的示例:
public class Test1 {
public static void main(String[] args) {
var list = Arrays.asList("Orange", "Apple", "Banana", "Grape", "XPack", "AKF") ;
var ret = calcLength(list, it -> it.length()) ;
System.err.printf("Length: %s%n", ret) ;
}
static <T, S> ArrayList<S> calcLength(List<T> arr, Function<T, S> fn) {
var list = new ArrayList<S>() ;
arr.forEach(t -> list.add(fn.apply(t))) ;
return list ;
}
}
使用这些概念加上Lambda表达式,我们可以像下面这样编写闭包和柯里化。
public class ClosureTest {
Function<Integer, Integer> add(final int x) {
Function<Integer, Integer> add(final int x) {
// 普通写法
// var partial = new Function<Integer, Integer>() {
// public Integer apply(Integer y) {
// return x + y;
// }
// };
// 使用Lambda表达式语法;注意这里不能使用var
Function<Integer, Integer> partial = y -> x + y ;
return partial;
}
return partial;
}
public static void main(String[] args) {
ClosureTest closure = new ClosureTest();
var c1 = closure.add(100) ;
var c2 = closure.add(200) ;
System.out.println(c1.apply(66));
System.out.println(c2.apply(66));
}
}
运行结果
166
266
以上是关于闭包的应用。
Java中也有许多内置的高阶函数,如java.util.Collections#sort方法:
public static void main(String[] args) {
var list = Arrays.asList("Apple", "Orange", "Banana", "Grape");
Collections.sort(list, (String a, String b) -> {
return a.compareTo(b);
});
System.err.printf("%s%n", list) ;
}
Java Stream相关API中也提供了许多高阶函数,比如forEach、map等。
2.2 纯函数
函数式编程倾向于使用递归而不是循环。在Java中,这可以通过使用流API或编写递归函数来实现。让我们来看一个计算数字阶乘的例子。还使用JMH对这些方法进行了基准测试,并在下方列出了每操作的纳秒数。
在传统的迭代方法中:
@State(Scope.Thread)
public class FactorialTest {
// 我们要使用JMH进行测试,所以通过@Param定义入参
@Param({"20"})
private long num ;
@Benchmark
public long factorial() {
long result = 1;
for (; num > 0; num--) {
result *= num;
}
return result;
}
public static void main(String[] args) throws Exception {
Options options = new OptionsBuilder()
.include(FactorialTest.class.getSimpleName())
.forks(1)
.build() ;
new Runner(options).run() ;
}
}
测试结果
Benchmark (num) Mode Cnt Score Error Units
FactorialTest.factorial 20 avgt 5 0.475 ± 0.013 ns/op
同样的功能也可以使用递归来实现,如下所示,这在函数式编程中更为青睐。
@State(Scope.Thread)
public class FactorialTest2 {
@Param({ "20" })
private long num;
@Benchmark
public long factorialRec() {
return factorial(num);
}
private long factorial(long n) {
return n == 1 ? 1 : n * factorial(n - 1);
}
public static void main(String[] args) throws Exception {
Options options = new OptionsBuilder()
.include(FactorialTest2.class.getSimpleName())
.forks(1)
.build();
new Runner(options).run();
}
}
测试结果
Benchmark (num) Mode Cnt Score Error Units
FactorialTest2.factorialRec 20 avgt 5 17.316 ± 0.792 ns/op
递归方法的缺点是,它通常会比迭代方法更慢(我们追求的优势在于代码的简洁性和可读性),并且由于每次函数调用都需要作为栈帧保存到堆栈中,可能会导致栈溢出错误。
我们还可以使用Stream进行递归调用
@Param({ "20" })
private long num;
@Benchmark
public long factorialRec() {
return LongStream.rangeClosed(1, num)
.reduce(1, (n1, n2) -> n1 * n2);
}
运行结果
Benchmark (num) Mode Cnt Score Error Units
FactorialTest2.factorialRec 20 avgt 5 17.618 ± 1.414 ns/op
与递归算法差不多。
在编写Java代码时,考虑到可读性和不可变性,可以考虑使用流API或递归;但如果性能至关重要,或者迭代次数将非常大,则应使用标准循环。
2.3 惰性求值(Lazy evaluations)
惰性求值(Lazy evaluation)或非严格求值是指推迟表达式的计算,直到其结果真正被需要时才进行计算。一般来说,Java执行的是严格求值,但对于像&&、||和?:这样的运算符,它会进行惰性求值。我们可以利用这一点在编写Java代码时实现惰性求值。
考虑下面这个例子,在这个例子中Java会急切地(eagerly)计算所有内容:
public static void main(String[] args) {
System.out.println(addOrMultiply(true, add(4), multiply(4))); // 8
System.out.println(addOrMultiply(false, add(4), multiply(4))); // 16
}
public static int add(int x) {
System.out.println("executing add");
return x + x;
}
public static int multiply(int x) {
System.out.println("executing multiply");
return x * x;
}
public static int addOrMultiply(boolean add, int onAdd, int onMultiply) {
return (add) ? onAdd : onMultiply;
}
执行结果
executing add
executing multiply
8
executing add
executing multiply
16
函数一早就被执行了。
我们可以使用Lambda表达式和高阶函数将此重写为惰性求值的版本:
public static void main(String[] args) {
UnaryOperator<Integer> add = t -> {
System.out.println("executing add");
return t + t;
};
UnaryOperator<Integer> multiply = t -> {
System.out.println("executing multiply");
return t * t;
};
System.out.println(addOrMultiply(true, add, multiply, 4));
System.out.println(addOrMultiply(false, add, multiply, 4));
}
public static <T, R> R addOrMultiply(
boolean add, Function<T, R> onAdd,
Function<T, R> onMultiply, T t) {
return (add ? onAdd.apply(t) : onMultiply.apply(t));
}
执行结果
executing add
8
executing multiply
16
我们可以看到只执行了所需的功能。
2.4 引用透明性(Referential transparency)
表示在程序中,一个函数调用可以用它的返回值来替换,而不改变程序的行为。换句话说,对于相同的输入,函数总是产生相同的结果,没有副作用。
遗憾的是,在Java中限制数据变异的方法并不多。然而,通过使用纯函数,并明确避免数据变异和重新赋值(使用我们之前讨论过的其他概念),可以实现这一目标。对于变量,我们可以使用final关键字,它是一个非访问修饰符,用于防止通过重新赋值来改变变量的值。
例如,下面的代码将在编译时产生错误:
final var list = Arrays.asList("Apple", "Orange") ;
// 你不能重新赋值
list = Arrays.asList("Pack", "XXXOOO") ;
但是,当变量持有对其他对象的引用时,这并不会起到作用。例如,即使使用了final
关键字,下面的对象变异仍然会发生:
final var list = new ArrayList<>() ;
// 我们还是可以添加数据
list.add("XXX") ;
list.add("OOO") ;
final 关键字允许引用变量的内部状态被修改,因此从函数式编程的角度来看,final 关键字仅对常量和捕获重新赋值有用。