2021年4月13日星期二

随便聊聊 Java 8 的函数式编程

函数式编程(Functional Programming)

首先,我们来了解一个叫做"编程范式"的概念。

什么是"编程范式"呢?简单来说就是指导我们编程的方法论,是一种教我们如何编写代码、如何组织代码的"解题"思路。

业界普遍将编程范式划分为两大类:指令式过程式,当然每个大类也可以接着往下细分,下面只列出了一些比较常见的子分类:

  • 指令式(Imperative)
    • 过程式(Procedural),比如:Fortran、C
    • 面向对象(Object Oriented),比如:C++、Java
  • 声明式(Declarative)
    • 逻辑式(Logic),比如:Prolog
    • 函数式(Functional),比如:Haskell、Erlang

在上面的分类中,有的同学看到 Java 被划分到了指令式范式的范畴可能会感觉到疑惑,其实指令式范式与声明式范式之间并没有绝对的界限,而且大有相互渗透的趋势,上述的分类也只是一个广义的划分,不用太过较真,稍微有个概念就行了。

指令式范式更关注过程,侧重于描述"如何去做",而声明式范式关注结果,侧重于描述"去做什么"

来看一个 Java 计算阶乘的例子:

// 给出明确的算法实现(指令式)public int factorialImperative(int n) { int f = 1; for (; n > 0; --n) {  f *= n; } return f;}// 仅仅给出了阶乘的递归定义(声明式)public int factorialDeclarative(int n) { return n == 0 ? 1 : n * factorialDeclarative(n - 1);}

什么是函数式编程

关于什么是函数式编程貌似也没有什么官方的定义,我好像也没有找到什么权威的解释,参考 wikipedia 以及自己的一些理解大概描述一下吧:

函数式编程是一种声明式的编程范式(以目标为导向,侧重于描述"去做什么"),通过函数运算以及函数组合的方式构建程序,并且避免使用共享状态(变量)以及可变对象。

好吧,总结的好像也比较抽象。没关系,还是有个概念就好,多写写多看看就有感觉了。

说说函数式编程中一些常见的概念吧:

  • First-class function(一言以蔽之:函数可以赋值给变量)
  • High-order function(一言以蔽之:函数可以作为另一个函数的入参或者出参)
  • Pure function(一言以蔽之:给定输入,输出总是固定的)
    • 补充说明,正是因为输出总是依赖输入,所以引申出一些其他的概念
      • Side Effect Free(没有副作用的,即不会污染上下文)
      • Reference Transparency(引用透明,即不依赖外部变量)
      • Lazy Evaluation(延迟求值,即调用的时机就不那么重要了)
  • 不可变(这个很好理解,当一切都是不可变,也就不需要考虑锁竞争了,也不用担心并发问题了,并行也变得简单起来)
  • 科里化(一种模块化和重用代码的技术,说白了其实就是闭包的一种实践)
  • 延迟列表、模式匹配还有结合器等等大家感兴趣的话可以自己去谷歌一下

Java 8 的函数式编程

Java 8 对函数式编程的支持

众所周知 Java 属于面向对象编程语言,广义上属于指令式范式的语言,但是没必要较真这个范式的分类,或是追求写出标准的函数式编程风格的代码,最重要的还是写出好的代码,业务价值OK、扩展性OK、维护性OK(当然好代码可不止这些标准)。

下面我们就来看看 Java 为了支持函数式编程,做了哪些努力。

首先,就是参数传递。Java 为了实现把函数当做参数来传递,引入了一个函数式接口(Functional Inteface)的概念。

函数式接口

简单来说函数式接口就是只定义一个方法的接口。比如我们经常会用到的 java.lang.Runnablejava.util.concurrent.Callable

但是这样的定义未免有点单薄,因为只要人为的在接口里多添加一个方法,那么这个接口就不是函数接口了,又或者说某个接口压根儿就不是函数式接口,但是这个接口也只定义了一个方法,恰巧也符合函数式接口的语义,比如 java.lang.AutoCloseable,这就会给我们使用带来很多歧义和误导。

Java 8 提供了 @FunctionalInterface 注解来解决上面提到的问题。跟泛型和 @Override 类似,@FunctionalInterface 也同样是编译器来给代码做约束的,提供安全性的同时也提高了代码的可读性(因为显示的给你标记了嘛)。

当然,这个注解也不是必须的,还是举 java.lang.AutoCloseable 这个例子,像下面的声明编译器也并不会报错,但是这并没有什么实际意义,这些都是代码里面的坏味道,切记不可这样写:

public static void main(String[] args) throws Exception { AutoCloseable unscientific = () -> {  System.out.println("不科学的示例..."); }; unscientific.close();}

在上面的示例代码中,引入了 Java 8 的一个新的语法特性,你应该注意到了,就是 () -> {}:一个参数块+一个箭头+一个代码块,这就是 Lamdba 表达式。

Lambda 表达式

Java 8 引入 Lambda 表达式的主要作用就是简化代码编写,它只是一个语法糖而已,底层还是基于函数接口来实现的,如下面的示例那样:

// 基于函数接口Runnable task1 = new Runnable() { @Override public void run() {  // business code }};// Lamdba 写法Runnable task2 = () -> { // business code};

以前,我们只能通过匿名内部类将代码作为数据(也可以说是行为)来传递,写过匿名内部类的小伙伴都知道,样板代码你肯定是躲不开的,可读性也没那么友好,而现在,Lambda 表达式给我们提供了一种更加紧凑的传递行为的方式。

可以把 Lambda 表达式理解为函数式接口的一个具体实现的实例。Java 8 也允许我们直接以内联的形式直接编写函数式接口的抽象实现,而且,还可以把整个表达式直接当成参数进行传递。

最简单的 Lambda 表达式可以用逗号分隔的参数列表、-> 箭头符号以及语句块:

Arrays.asList("a", "b", "d")  .forEach(e -> System.out.println(e));

Lambda表达式可能会有返回值,编译器会根据上下文推断返回值的类型,如果lambda的语句块只有一行,可以省略return关键字。

// 传统的方式Arrays.asList("a", "d", "c").sort(new Comparator<String>() {   @Override   public int compare(String e1, String e2) {    return e1.compareTo(e2);   }  });// Lambda 语法Arrays.asList("a", "d", "c").sort((e1, e2) -> {   int result = e1.compareTo(e2);    return result;  });// Lambda 语法精简Arrays.asList("a", "d", "c").sort((e1, e2) -> e1.compareTo(e2));

关于 Lamdba 再说两点:

  • 对局部变量的限制
    我们在使用过程中,常常发现 Lamdba 引用局部变量必须是final的或者说是隐式的final的,这主要是因为实例变量和局部变量背后的实现有一个关键不同:实例变量都存储在堆中,而局部变量则保存在栈上。堆是线程共享的,而栈是线程私有的,换句话说,Lambda 表达式引用的是值,而不是变量。
  • 方法引用
    Java 8 还提供了方法应用来进一步简化代码的编写,就像上个示例中代码,我们还能进一步简写为:
    // 方法引用Arrays.asList("a", "b", "d").sort(String::compareTo);

好了,刚刚介绍了 Java 8 的函数式接口还有 Lambda 表达式,可以进入下一个阶段了,就是流(Stream)。

流(Stream)

举个电商计算 ROI(投资回报率)的简单例子,公式如下:

ROI =[(收入-成本)/ 投入 ]*100 

常规操作可能写成这样:

multiply(divide(subtract(收入,成本),投入),100)

但如果我们换一种可读性更高的写法:

subtract(收入,成本).divide(投入).multiply(100)

是不是立马眼前一亮!我们知道 . 操作符是 Java 里面的方法调用,为了支持上面的这种级联操作,Java 8 让每个方法都返回一个通用的类型,即:Stream。

流有两个特点

  • 流水线(简单来说就是函数的级联调用,可以类比SQL语句)
  • 内部迭代(简单来说就是不需要像集合那样显示的处理迭代)

"流水线"这个特点就像刚刚计算ROI公式的那个例子,比较明了,我们来看看内部迭代:

在 Java 8 以前,我们大量的使用集合,对于稍微复杂一些的操作(过滤、分组、排序等组合操作)我们通常需要显示的编写大量的代码,当然,这也是无法避免的,这又回到文章开头提到的指令式范式的特点上面了,在没有声明式编程语法的支持下,你只能给出具体的算法实现,这个就不举例子了,大家应该深有体会,还是举个简单的例子吧:

int count=0; for (Person person : persons) {  if (person.isFrom("江苏"))){   count++;  } }

现在 Java 8 引入了流,在流上支持了声明式的编程风格,一切就都变得豁然开朗起来,语义也更加丰满起来了:

long count = persons.stream()  .filter(person -> person.isFrom("江苏"))  .count()

流的惰性求值

  • Stream 上的有两种操作:中间操作和终止操作
    • 中间操作仍然返回 Stream 对象,而终止操作返回的是具体的结果(或者说归约,按照给定的规则对流中的元素进行加工,然后输出到结果中)
    • 常见的中间操作:mapfilterlimit
    • 常见的终止操作:maxcollectforEach
  • Stream 补充说明
    • 流的目的在于表达计算,集合讲的是数据,而流讲的是计算。粗略地说,集合与流之间的差异就在于什么时候进行计算。流就像是一个延迟创建的集合:只有在消费者要求的时候才会计算值
    • 从有序集合生成流时会保留原有的顺序,由列表生成的流其元素顺序与列表一致
    • 流操作可以顺序执行,也可以并行执行
    • 注意,和迭代器类似,流只能消费一次

常用的流操作

map

如果有一个函数可以将一种类型的值转换成另外一种类型,map 操作就可以使用该函数,将一个流中的值转换成一个新的流。

List<String> results = Stream.of("a", "c", "c")  .map(String::toUpperCase)  .collect(Collectors.toList());// output: [A, B, C]
filter

遍历数据并检查其中的元素时,可尝试使用 Stream 中提供的新方法 filter

List<Integer> results = Stream.of("1", "2", "3")  .map(Integer::parseInt)  .filter(n -> n % 2 == 0)  .collect(Collectors.toList());// output: [2]
flatMap

flatMap 方法可用 Stream 替换值,然后将多个 Stream 连接成一个 Stream

List<Integer> results = Stream.of(Arrays.asList(1, 2), Arrays.asList(3, 4))  .flatMap(List::stream)  .collect(Collectors.toList());// output: [1, 2, 3, 4]
reduce

reduce 操作可以实现从一组值中生成一个值。

int result1 = IntStream.of(1, 2, 3, 4)  .sum();int result2 = Stream.of(1, 2, 3, 4)  .reduce(0, (acc, element) -> acc + element);int result3 = Stream.of(1, 2, 3, 4)  .reduce(0, Integer::sum);// output: 10

Collector

最后,我们来看一看 Collector接口。Stream 接口中定义了一个 <R, A> R collect(Collector<? super T, A, R> collector); 的方法,接收一个 Collector 类型的参数。Collector 顾名思义,就是收集器,就是按照一定的规则,对流进行数据的归约操作。

刚刚 mapfilter 的示例中都用到了 Stream.collect(Collectors.toList()); :将流中的元素输出到一个 List 中去。

我们先看一下 Collector 的接口定义:

// 建立新的结果容器Supplier<A> supplier();// 将元素添加到结果容器BiConsumer<A, T> accumulator();// 合并两个结果容器BinaryOperator<A> combiner();// 对结果容器应用最终转换Function<A, R> finisher();// 定义了收集器的行为(尤其是关于流是否可以并行归约),以及可以使用哪些优化的提示。Set<Characteristics> characteristics();

如果我们想把流中的元素归约到一个 List<Integer> 中,那么首先,你得有一个收集结果的容器:

Supplier<List<Integer>> containerSupplier = ArrayList::new;

然后我们需要一个累加函数,将流中的一个个元素放到结果容器中:

BiConsumer<List<Integer>, Integer> accumulator = List::add;

然后,我们需要告诉编译器这个流是否可以并行归约,或是可以做哪些优化:

Set<Collector.Characteristics> characteristics = Collections.unmodifiableSet(EnumSet.of(Collector.Characteristics.IDENTITY_FINISH));

然后如果是在并行的场景下,我们需要将并行的各个部分进行合并,这个时候,就需要一个合并的函数:

BinaryOperator<List<Integer>> combiner = (List<Integer> left, List<Integer> right) -> { left.addAll(right); return left;};

最后,我们需要一个类型转换的函数,将流中的元素转换成归约的目标类型:

Function<Integer, Integer> finisher = (Integer i) -> (Integer) i;

所以整体思路就是动态新建了一个目标集合,然后遍历这个流,将流中的元素依次添加到目标集合中,如果不考虑并行的情况,其实Collector就是干了这么一件事。如果你要实现自己的收集器,大体上,也就是跟这5个方法打交道了。

当然,Java 已经给我们提供了不少常用的收集器了,用于实现像 joingroupingBy 等常用操作,可以直接看一下 Collectors 的类注释,官方很贴心的给我们提供了不少示例代码。

好了,就写这么多吧,希望对大家有所帮助。









原文转载:http://www.shaoqun.com/a/684743.html

跨境电商:https://www.ikjzd.com/

tradeindia:https://www.ikjzd.com/w/2305

淘粉吧返利:https://www.ikjzd.com/w/1725


函数式编程(FunctionalProgramming)首先,我们来了解一个叫做"编程范式"的概念。什么是"编程范式"呢?简单来说就是指导我们编程的方法论,是一种教我们如何编写代码、如何组织代码的"解题"思路。业界普遍将编程范式划分为两大类:指令式和过程式,当然每个大类也可以接着往下细分,下面只列出了一些比较常见的子分类:指令式(Impe
万国邮政联盟:https://www.ikjzd.com/w/861
eprice:https://www.ikjzd.com/w/1325
review:https://www.ikjzd.com/w/2735
我和老公两地分居了近8年:http://www.30bags.com/a/253194.html
Upfluence:https://www.ikjzd.com/w/2020
聊聊锦上添花的亚马逊新品期!!!:https://www.ikjzd.com/home/133352

没有评论:

发表评论