Java中的七种函数式编程技巧

开发 前端
在Java中限制数据变异的方法并不多。然而,通过使用纯函数,并明确避免数据变异和重新赋值(使用我们之前讨论过的其他概念),可以实现这一目标。对于变量,我们可以使用final关键字,它是一个非访问修饰符,用于防止通过重新赋值来改变变量的值。

环境: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 关键字仅对常量和捕获重新赋值有用。

责任编辑:武晓燕 来源: Spring全家桶实战案例源码
相关推荐

2011-02-22 16:09:53

Eclipse调试

2021-10-19 14:51:33

说服力IT主管CIO

2022-07-01 08:00:44

异步编程FutureTask

2015-09-02 12:12:13

2019-11-11 16:44:20

机器学习Python算法

2013-01-07 10:14:06

JavaJava枚举

2022-05-10 08:08:01

find命令Linux

2020-01-14 08:00:00

.NET缓存编程语言

2022-07-25 10:15:29

垃圾收集器Java虚拟机

2022-03-14 07:40:14

RibbonSpringNacos

2016-03-16 10:39:30

数据分析数据科学可视化

2010-10-15 10:02:01

Mysql表类型

2023-02-14 08:32:41

Ribbon负载均衡

2020-01-14 11:09:36

CIO IT技术

2019-09-06 09:00:00

开发技能代码

2017-06-14 16:44:15

JavaScript原型模式对象

2017-08-31 14:57:53

数据库MySQLJOIN

2021-07-16 09:55:46

数据工具软件

2017-06-02 09:52:50

2010-08-31 10:57:36

点赞
收藏

51CTO技术栈公众号