函数式编程中函数有哪些玩法?(以Vavr为例)

91 阅读4分钟

函数式编程中函数有哪些玩法?(以Vavr为例)

未经允许禁止转载!

Java8 引入了函数式相关概念,比如函数接口,方法引用,lambda表达式等等。本文中我们看看如何在语言的基础上实现更加复杂的函数式思想。

1. Composition 组合

我们可以组合多个函数,如 Vavr 文档中的例子:

// 本文大部分例子取自 Vavr 文档
Function1<Integer, Integer> plusOne = a -> a + 1;
Function1<Integer, Integer> multiplyByTwo = a -> a * 2;
// 简单使用:
Option.of(2).map(plusOne).map(multiplyByTwo).get();
// 组合成新的函数
Function1<Integer, Integer> add1AndMultiplyBy2 = plusOne.andThen(multiplyByTwo);
then(add1AndMultiplyBy2.apply(2)).isEqualTo(6);
// 或者使用compose实现,类似于复合函数
Function1<Integer, Integer> add1AndMultiplyBy2 = multiplyByTwo.compose(plusOne);

在很多方法执行链路中,多个步骤可以组合成一个函数。这样做的好处是可以写出更加清晰的代码,同时方便实现代码复用。

Optional 不是 Monad,使用组合的结果可能不一致,推荐使用 Option 类型

Function<Integer, Integer> f = i -> i == 0 ? null : i;
Function<Integer, String> g = i -> i == null ? "NaN" : i.toString();
// the following are not equal
Optional<String> containsNaN = Optional.of(0).map(f.andThen(g));
Optional<String> isEmpty = Optional.of(0).map(f).map(g);

如上代码中,依次调用和组合调用的结果不一致。如果使用Option类型的话,会返回 NaN 结果。总的来说, Optional 类型用于处理空指针问题,Option类型用于实现函数式编程。

2. Lifting 提升

偏函数指的是函数的入参是参数类型的子集,比如除法操作时要求被除数不能为0。对于偏函数,我们可以将其提升为纯函数。

Function2<Integer, Integer, Integer> divide = (a, b) -> a / b;
Function2<Integer, Integer, Option<Integer>> safeDivide = Function2.lift(divide);
// = None
Option<Integer> i1 = safeDivide.apply(1, 0); 
// = Some(2)
Option<Integer> i2 = safeDivide.apply(4, 2); 

提升的好处是避免函数异常退出,在函数式思想中,异常应该以值的形式处理,以 Monad 类型(Either, Option, Try) 等返回,保证所有的异常都显式处理。

3. 偏应用函数

函数中指定某些参数为固定值,返回的结果仍然为一个函数。

Function2<Integer, Integer, Integer> sum = (a, b) -> a + b;
Function1<Integer, Integer> add2 = sum.apply(2); 

then(add2.apply(4)).isEqualTo(6);

使用偏应用函数可以保证避免重复传递参数,如以上代码中的add2方法,可以方便地实现复用。

4. 柯里化 Currying

Function2<Integer, Integer, Integer> sum = (a, b) -> a + b;
Function1<Integer, Integer> add2 = sum.curried().apply(2); 

then(add2.apply(4)).isEqualTo(6);

柯里化可以实现所有的多参数函数,转换为嵌套的单参数函数。Java中并没有针对单参数函数的表示优化,其他语言比如 Kotlin, 对于单参数方法可以不写括号,方便实现一些 DSL。

// 方法声明
Function3<Integer, Integer, Integer, Integer> sum = (a, b, c) -> a + b + c;
Function1<Integer, Function1<Integer, Integer>> add2 = sum.curried().apply(2);

Java中的方法声明比较难以阅读,简单来说,转换后的参数除了最后一个,都被Function1所包围。一些其他语言会区分入参和返回类型,函数表达如: (int, int, int) => int 。haskell 语言中大量使用了柯里化函数。

def fold[A1 >: A](z: A1)(op: (A1, A1) => A1): A1
def groupMap[K, B](key: (A) => K)(f: (A) => B): Map[K, List[B]]

以上是scala中List下的两个方法,其参数进行了柯里化,可以发现其表达比Java stream的实现更为清晰。如 groupMap方法,不仅保证了方法实现的性能,还可以方便实现链式调用;相反,在Java中实现此方法则需要好多行代码(多种实现:使用 Collector, 或者 guava中的mapValues,或者多次使用stream#map),这里不是说代码行数少就好,而是说编程的概念可以用代码清楚地表达。

总的来说,柯里化和偏应用函数的概念上区别很大,实际使用中应注意区分。两者都可以实现懒计算。这些函数和多参数函数直接可以相互转换。

5. 记忆化 Memoization

Function0<Double> hashCache =
        Function0.of(Math::random).memoized();

double randomValue1 = hashCache.apply();
double randomValue2 = hashCache.apply();

then(randomValue1).isEqualTo(randomValue2);

如上代码中hashCache函数返回值永远不变,虽然其内部封装的 Math::random 不是纯函数,但是其对外表现为一个纯函数(初次调用后),有时我们对于纯函数的定义可以弱化一些。

这里的记忆化体现了函数式编程的简洁性,对于很多常用的方法实现或者任务编排,可以使用声明式编程的形式进行简化。

实践指南

推荐实际项目中灵活应用相关的函数思想,专注于可读性和代码复用。本文中的很多例子比较简单,实践中可以使用静态方法的形式定义函数,使用时函数时可以用方法引用。

参考文章

  1. Vavr Documentation
  2. Optional Is a Law-breaking Monad but a Good Type