Java中的七种函数编程技术 - foojay

21-05-13 banq

根据维基百科:函数式编程是一种编程范例-一种构建计算机程序的结构和元素的样式-会将计算视为对数学函数的评估,并避免更改状态和可变数据。

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

  • 无数据突变:这意味着在创建数据对象后不应更改它。
  • 无隐式状态:应避免隐藏/隐式状态。在函数式编程状态下,不消除状态,而是使其可见和显式

这表示:

  • 无副作用:功能或操作不得在其功能范围之外更改任何状态。即,一个函数应仅将一个值返回给调用者,并且不应影响任何外部状态。这意味着程序更易于理解。
  • 仅纯函数:功能代码是幂等的。函数应仅基于传递的参数返回值,并且不应该影响(副作用)或依赖于全局状态。对于相同的参数,此类函数始终会产生相同的结果。

 

一等和高阶函数

一等函数(作为一流公民的函数)意味着您可以将函数分配给变量,将函数作为参数传递给另一个函数,或者从另一个函数返回一个函数。不幸的是,Java不支持此功能,因此使得诸如闭包,柯里化和高阶函数之类的概念不太容易编写。

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

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

这不是执行高阶函数的最好方法,但这就是Java中的样子,而且还不错。

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

       //我们将数组和FnFactory的匿名内部类实例作为mapForEach方法的参数传递。
        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]
    }

    //该方法将数组和FnFactory的实例作为参数
    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 //不会做任何事情,只是提供信息而已。.
    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");
        //我们将数组和一个lambda表达式作为参数传递给mapForEach方法。
        var out = mapForEach(list, it -> it.length());
       //这可以进一步简化为“ mapForEach(list,String :: length);”,我正在编写扩展版本以提高可读性
        System.out.println(out); // [6, 5, 6, 5]
    }

   //该方法将一个数组和一个Function实例作为参数(我们已将自定义接口替换为内置接口)
    static <T, S> ArrayList<S> mapForEach(List<T> arr, Function<T, S> fn) {
        var newArray = new ArrayList<S>();
        //我们正在执行Function实例中的方法
        arr.forEach(t -> newArray.add(fn.apply(t)));
        return newArray;
    }
}

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

public class ClosureSample {
     //这是一个返回函数接口实例的高阶函数
    Function<Integer, Integer> add(final int x) {
       //这是一个闭包,即一个包含Function接口的匿名内部类实例的变量
        //使用外部作用域中的变量
        var 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;
            }
        };
        //这里返回闭包函数实例
        return partial;
    }

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

        //我们正在使用add方法来创建更多变量
        var add10 = sample.add(10);
        var add20 = sample.add(20);
        var 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 {

   //这是一个返回函数接口实例的高阶函数

    Function<Integer, Integer> add(final int x) {

        // lambda表达式在这里作为闭包返回

       //变量x从此方法的外部范围获得,该方法声明为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

        var add10 = sample.add(10);

        var add20 = sample.add(20);

        var 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:

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

//这可以简化为“ Collections.sort(list,Comparator.naturalOrder());”,我正在编写扩展版本以提高可读性
Collections.sort(list, (String a, String b) -> {
    return a.compareTo(b);
});

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

 

纯函数

正如我们已经看到的,纯函数应该仅基于传递的参数返回值,而不应该影响或依赖于全局状态。在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或编写递归函数来实现。让我们看一个计算数字阶乘的例子。

在传统的递归方法中:

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是严格的立即赋值评估,但操作数 &&, || 和?: 是慵懒惰赋值。在编写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;
    }
}

我们可以使用lambda表达式和高阶函数将其重写为延迟评估的版本:

public class LazySample {
    public static void main(String[] args) {
        //这是一个lambda表达式,表现为闭包
        UnaryOperator<Integer> add = t -> {
            System.out.println("executing add");
            return t + t;
        };
      //这是一个lambda表达式,表现为闭包
        UnaryOperator<Integer> multiply = t -> {
            System.out.println("executing multiply");
            return t * t;
        };
        //传递Lambda闭包而不是普通函数
        System.out.println(addOrMultiply(true, add, multiply, 4));
        System.out.println(addOrMultiply(false, add, multiply, 4));
    }

    //这是一个高阶函数
    static <T, R> R addOrMultiply(
            boolean add, Function<T, R> onAdd,
            Function<T, R> onMultiply, T t
    ) {
        // Java的?会懒惰计算表达式,因此仅执行所需的方法
        return (add ? onAdd.apply(t) : onMultiply.apply(t));
    }
}

 

类型系统

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

 

引用透明

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

不幸的是,没有很多方法可以限制Java中的数据突变,但是通过使用纯函数以及使用其他概念明确避免数据突变和重新分配,我们可以在前面看到这一点。对于变量,我们可以使用final关键字,它是不可访问的修饰符,以避免因重新分配而引起的突变。

例如,以下将在编译时产生错误:

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

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

但是,当变量持有对其他对象的引用时,这将无济于事,例如,以下更改将与final关键字无关。

final var list = new ArrayList<>();

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

final关键字允许对引用变量的内部状态进行更改,因此从功能编程的角度来看,final关键字仅对常量和捕获重新分配有用。

 

数据结构

使用函数编程技术时,建议使用函数数据类型,例如堆栈,映射和队列。因此,在函数编程中作为数据存储,映射比数组或哈希集更好。

  

结论

对于那些试图在Java中应用某些函数式编程技术的人来说,这只是一个介绍。用Java可以做的事情还很多,而Java 8添加了很多API,使使用Java进行函数性编程变得容易,例如流API,Optional接口,功能性接口等。

 

1
猜你喜欢