Java中的函数编程技术 - Deepu K Sasidharan

19-08-01 banq
              

关于函数式编程(FP)有很多炒作,很多很酷的孩子都在做这件事,但它并不是一颗银弹。与其他编程范式/风格一样,函数式编程也有其优点和缺点,人们可能更喜欢一种范式而不是另一种范式。如果您是Java开发人员并想进入函数式编程,请不要担心,因为Java,您不必学习Haskell或Clojure等函数式编程语言(甚至是Scala或JavaScript,尽管它们不是纯函数式编程语言)

什么是函数式编程? 

根据维基百科,

函数式编程是一种编程范式 - 一种构建计算机程序结构和元素的方式 - 将计算视为数学函数的评估并避免改变状态和可变数据。

因此,在函数式编程中,有两个非常重要的规则

  • 无数据突变:这意味着数据对象在创建后不应更改。
  • 没有隐式状态:应该避免隐藏/隐含状态。在函数式编程中,状态不会被消除,相反,它是可见的和显式的

这意味着:

  • 无副作用:功能或操作不应更改其功能范围之外的任何状态。即,函数应该只向调用者返回一个值,不应该影响任何外部状态。这意味着程序更容易理解。
  • 仅限纯函数:函数代码是幂等的。函数应仅基于传递的参数返回值,不应影响(副作用)或依赖于全局状态。这些函数总是为相同的参数产生相同的结果。

使用函数式编程并不意味着全部或全部,您总是可以使用函数式编程概念来补充面向对象的概念,尤其是在Java中。无论您使用何种范例或语言,都可以尽可能利用函数式编程的优势。

Java中的函数编程

那么让我们看看如何在Java中应用上面的一些函数式编程概念。我们将使用Java 11,因为它目前是LTS版本。

作为一等公民的函数意味着您可以将函数赋值给变量,将函数作为参数传递给另一个函数或从另一个函数返回函数。遗憾的是,Java不支持这一点,因此使得闭包,currying和高阶函数等概念的编写不太方便。

与Java中最接近的第一类函数是Lambda表达式。还有像一些内置的功能接口Function,Consumer,Predicate,Supplier等下java.util.function可用于函数编程软件包。

只有当函数将一个或多个函数作为参数或者作为结果返回另一个函数时,才能将函数视为高阶函数。我们可以在Java中获得的最接近高阶函数的是使用Lambda表达式和内置的Functional接口。

这不是做高阶函数最好看的方法,但这就是它在Java中的表现,而不是那么糟糕的IMO。

public class HocSample {
    public static void main(String args) {
        var list = Arrays.asList("Orange", "Apple", "Banana", "Grape");

        // we are passing an array and an anonymous inner class instance of FnFactory as arguments to mapForEach method.
        var out = mapForEach(list, new FnFactory<String, Object>() {
            @Override
            public Object execute(final String it) {
                return it.length();
            }
        });
        System.out.println(out); // [6, 5, 6, 5]
    }

    // The method takes an array and an instance of FnFactory as arguments
    static <T, S> ArrayList<S> mapForEach(List<T> arr, FnFactory<T, S> fn) {
        var newArray = new ArrayList<S>();
        // We are executing the method from the FnFactory instance
        arr.forEach(t -> newArray.add(fn.execute(t)));
        return newArray;
    }

    @FunctionalInterface // this doesn't do anything it is just informative.
    public interface FnFactory<T, S> {
        // The interface defines the contract for the anonymous class
        S execute(T it);
    }
}

幸运的是,实际上可以使用内置Function接口和lambda表达式语法进一步简化上面的示例。

public class HocSample {
    public static void main(String args) {
        var list = Arrays.asList("Orange", "Apple", "Banana", "Grape");
        // we are passing the array and a lambda expression as arguments to mapForEach method.
        var out = mapForEach(list, it -> it.length()); 
        // This can be further simplified to "mapForEach(list, String::length);", I'm writing the expanded version for readability
        System.out.println(out); // [6, 5, 6, 5]
    }

    // The method takes an array and an instance of Function as arguments (we have replaced the custom interface with the built-in one)
    static <T, S> ArrayList<S> mapForEach(List<T> arr, Function<T, S> fn) {
        var newArray = new ArrayList<S>();
        // We are executing the method from the Function instance
        arr.forEach(t -> newArray.add(fn.apply(t)));
        return newArray;
    }
}

使用这些概念和lambda表达式,我们可以编写闭包和currying,如下所示:

public class ClosureSample {
    // this is a higher-order-function that returns an instance of Function interface
    Function<Integer, Integer> add(final int x) {
        // this is a closure, i.e, a variable holding an anonymous inner class instance of the Function interface
        // which uses variables from the outer scope
        Function<Integer, Integer> partial = new Function<Integer, Integer>() {
            @Override
            public Integer apply(Integer y) {
                // variable x is obtained from the outer scope of this method which is declared as final
                return x + y;
            }
        };
        // The closure function instance is returned here
        return partial;
    }

    public static void main(String args) {
        ClosureSample sample = new ClosureSample();

        // we are currying the add method to create more variations
        Function<Integer, Integer> add10 = sample.add(10);
        Function<Integer, Integer> add20 = sample.add(20);
        Function<Integer, Integer> add30 = sample.add(30);

        System.out.println(add10.apply(5)); // 15
        System.out.println(add20.apply(5)); // 25
        System.out.println(add30.apply(5)); // 35
    }
}

可以使用如下的lambda表达式进一步简化这一过程:

public class ClosureSample {
    // this is a higher-order-function that returns an instance of Function interface
    Function<Integer, Integer> add(final int x) {
        // The lambda expression is returned here as closure
        // variable x is obtained from the outer scope of this method which is declared as final
        return y -> x + y;
    }

    public static void main(String args) {
        ClosureSample sample = new ClosureSample();

        // we are currying the add method to create more variations
        Function<Integer, Integer> add10 = sample.add(10);
        Function<Integer, Integer> add20 = sample.add(20);
        Function<Integer, Integer> add30 = sample.add(30);

        System.out.println(add10.apply(5));
        System.out.println(add20.apply(5));
        System.out.println(add30.apply(5));
    }
}

Java中还有许多内置的高阶函数,例如这里 java.util.Collections的sort方法

List<String> list = Arrays.asList("Apple", "Orange", "Banana", "Grape");

// This can be simplified as "Collections.sort(list, Comparator.naturalOrder());", I'm writing the expanded version for readability
Collections.sort(list, (String a, String b) -> {
    return a.compareTo(b);
});

System.out.println(list); // [Apple, Banana, Grape, Orange]

Java流API还提供了许多有趣的高阶函数,如forEach,map等。

纯粹的函数

正如我们所看到的,纯函数应该仅基于传递的参数返回值,并且不应该影响或依赖于全局状态。除了某些涉及检查过的异常的情况外,可以在Java中执行此操作。

这很简单,下面这是一个纯函数。它将始终为给定输入返回相同的输出,并且其行为具有高度可预测性。如果需要,我们可以安全地缓存该方法结果。

public static int sum(int a, int b) {
    return a + b;
}

如果我们在此函数中添加额外的行,则行为变得不可预测,因为它现在具有影响外部状态的副作用。

static Map map = new HashMap<String, Integer>();

public static int sum(int a, int b) {
    var c = a + b;
    map.put(a + "+" + b, c);
    return c;
}

因此,尽量保持您的函数简单明了。

递归

函数式编程有利于循环递归。在Java中,这可以通过使用流API或编写递归函数来实现。让我们看一个计算数字阶乘的例子。

我还使用JMH对这些进行了基准测试,并提到了下面的纳秒/操作

在传统的递归方法中:

public class FactorialSample {
    // benchmark 9.645 ns/op
    static long factorial(long num) {
        long result = 1;
        for (; num > 0; num--) {
            result *= num;
        }
        return result;
    }

    public static void main(String args) {
        System.out.println(factorial(20)); // 2432902008176640000
    }
}

使用如下的递归可以完成相同的操作,这在函数式编程中是有利的。

public class FactorialSample {
    // benchmark 19.567 ns/op
    static long factorialRec(long num) {
        return num == 1 ? 1 : num * factorialRec(num - 1);
    }

    public static void main(String args) {
        System.out.println(factorialRec(20)); // 2432902008176640000
    }
}

递归方法的缺点是,与大多数时候的迭代方法相比,它会更慢(我们的目标是代码简单性和可读性)并且可能导致堆栈溢出错误,因为每个函数调用都需要保存为堆栈的框架。为了避免这种尾递归是首选,特别是当递归过多次时。在尾递归中,递归调用是函数执行的最后一件事,因此编译器不保存函数堆栈帧。大多数编译器可以优化迭代代码优化尾递归代码,从而避免性能损失。遗憾的是,Java编译器不执行此优化。

现在使用尾递归,相同的函数可以写成如下,但Java并没有对此进行优化,尽管有一些变通方法,但它仍然在基准测试中表现更好。

public class FactorialSample {
    // benchmark 16.701 ns/op
    static long factorialTailRec(long num) {
        return factorial(1, num);
    }

    static long factorial(long accumulator, long val) {
        return val == 1 ? accumulator : factorial(accumulator * val, val - 1);
    }

    public static void main(String args) {
        System.out.println(factorialTailRec(20)); // 2432902008176640000
    }
}

我们也可以使用Java流库进行递归,但目前它的递归速度比正常慢。

public class FactorialSample {
    // benchmark 59.565 ns/op
    static long factorialStream(long num) {
        return LongStream.rangeClosed(1, num)
                .reduce(1, (n1, n2) -> n1 * n2);
    }

    public static void main(String args) {
        System.out.println(factorialStream(20)); // 2432902008176640000
    }
}

在编写Java代码时考虑使用流API或递归以实现可读性和不变性,但如果性能至关重要或迭代次数很多,则使用标准循环。

懒计算

惰计算或非严格计算是延迟表达式计算直到需要其结果。在一般情况下,Java没有严格的这样评估计算,但对于像操作数&&,||而?:它确实懒惰的延迟计算。在编写java代码时,我们可以利用它来进行惰性求值。

Java为热切评估计算的示例。

public class EagerSample {
    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
    }

    static int add(int x) {
        System.out.println("executing add"); // this is printed since the functions are evaluated first
        return x + x;
    }

    static int multiply(int x) {
        System.out.println("executing multiply"); // this is printed since the functions are evaluated first
        return x * x;
    }

    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 class LazySample {
    public static void main(String args) {
        // This is a lambda expression behaving as a closure
        Function<Integer, Integer> add = t -> {
            System.out.println("executing add");
            return t + t;
        };
        // This is a lambda expression behaving as a closure
        Function<Integer, Integer> multiply = t -> {
            System.out.println("executing multiply");
            return t * t;
        };
        // Lambda closures are passed instead of plain functions
        System.out.println(addOrMultiply(true, add, multiply, 4));
        System.out.println(addOrMultiply(false, add, multiply, 4));
    }

    // This is a higher-order-function
    static <T, R> R addOrMultiply(
            boolean add, Function<T, R> onAdd,
            Function<T, R> onMultiply, T t
    ) {
        // Java evaluates expressions on ?: lazily hence only the required method is executed
        return (add ? onAdd.apply(t) : onMultiply.apply(t));
    }
}

输出如下,我们可以看到只执行了所需的函数:

executing add
8
executing multiply
16

类型系统

Java有一个强大的类型系统,并且随着var关键字的引入,它现在也有相当不错的类型推断。与其他函数式编程语言相比,唯一缺少的是case类。有关未来Java版本的值类和案例类的建议。让我们希望他们成功。

 引用透明度:函数程序没有赋值语句,也就是说,函数程序中的变量值一旦定义就不会改变。这消除了任何副作用的可能性,因为任何变量都可以在任何执行点用其实际值替换。因此,函数程序是引用透明的。

遗憾的是,限制Java中的数据变异的方法并不多,但是通过使用纯函数并使用我们之前看到的其他概念明确地避免数据突变和重新分配,可以实现这一点。对于变量,我们可以使用final关键字作为非访问修饰符来避免重新分配的突变。

例如,下面的代码会在编译时产生错误:

final var list = Arrays.asList("Apple", "Orange", "Banana", "Grape");

list = Arrays.asList("Earth", "Saturn");

但是,当变量持有对其他对象的引用时,这将无济于事,例如,无论最终关键字如何,以下变异都将起作用。

final var list = new ArrayList<>();

list.add("Test");
list.add("Test 2");

finalkeyword允许对引用变量的内部状态进行变异,因此从函数式编程透视图中,final关键字仅对常量有用并且可以捕获重新分配。

数据结构

使用函数式编程技术时,鼓励使用堆栈,映射和队列等函数数据类型。因此,映射在函数式编程中比数组或散列集更好地作为数据存储。

结论

Java 8添加了大量API,以便在Java中轻松进行函数式编程,如流API,可选接口,功能接口等。正如我之前所说,函数式编程不是一个灵丹妙药,但它为更易理解,可维护和可测试的代码提供了许多有用的技术。它可以与命令式和面向对象的编程风格完美地共存。

              

2