未分类Java 杂谈程序员

Java中的函数式编程

2019-03-20  本文已影响6人  alonwang

前言

JDK8引入的Lambda表达式和Stream为Java平台提供了函数式编程的支持,极大地提高了开发效率.本文结合网络资源和自身使用经验,介绍下Java中的函数式编程

Java中的函数式编程

出现的原因

语言面临着要么改变,要么衰亡的压力. Java是传统的命令式编程,而函数式编程.是一种更"高级"的编程范式,Java为了支持它,推出了Lambda表达式和Stream.

函数式编程 VS 命令式编程

一言以蔽之:

函数式编程是:

"我现在想要这样东西(怎么办到我不管,你来处理)"

命令式编程是:

"你要先...,再...,最后...,就能拿到这样东西了"

事实上,函数式编程的底层实现还是命令式编程,就像面向对象语言核心部分(如JVM)是由面向过程语言(如C)实现的.毕竟脏活累活总是要有人去做的.

举个栗子

以一个比较苹果重量的Comparator为例,类Apple定义如下

public class Apple{
    private int weight;
    private int type;
    public int getWeight(){
        return this.weight;
    }
    public int getType(){
        return this.type;
    }
}

如果按照匿名类实现,代码会是这样,总体来说比较繁琐.

Comparator<Apple> byWeight=new Comparator<>(){
    @Override
    public int compareTo(Apple a1,Apple a2) {
        return a1.getWeight().compareTo(a2.getWeight());
    }
}

如果使用Lambda表达式,最繁琐的形式会是这样

Comparator<Apple> byWeight=
(Apple a1,Apple a2) -> {return a1.getWeight().compareTo(a2.getWeight());}

要搞清楚Lambda表达式的工作原理,首先要了解它的语法以及函数式接口

Lambda表示式 VS 方法

Lambda的语法结构如下

// 参数列表 箭头 方法体
( ParameterType1 param1,ParameterType2 param2... ) -> { ... } 

方法的语法结构如下(暂不考虑throws)

访问权限 ReturnType methodName(ParameterType1 param1,ParameterType2 param2...){
    ... 
}

可以看出,Lambda表达式可以看做方法的简化形式: 没有访问权限,返回类型以及方法名.并且它还可以进一步简化.

函数式接口

有且只有一个抽象方法的接口

首先澄清一下这里抽象方法的定义(java doc)

  1. 接口中的default方法不是抽象方法,因为它有默认实现
  2. 如果接口中的方法覆盖了java.lang.Object中的方法,也不做计数

下面以Comparator为例(适当精简)

@FunctionalInterface
public interface Comparator<T> {
    int compare(T o1, T o2);//1
  boolean equals(Object obj);//2
    default Comparator<T> reversed() {//3
        return Collections.reverseOrder(this);
  }
}

@FunctionalInterface用来标识一个接口是函数式接口,它和@Override注解类似,只是编译时起检查作用,如果这个接口定义不符合的话,编译时就会报错,如果一个接口符合函数式接口的定义,即使没有这个注解依然是有效的

再来看下Comparator中有几个抽象方法

  1. 是抽象方法
  2. 覆盖了Object.equal()方法,所以不是
  3. 是default方法,也不是抽象方法

只有一个抽象方法,因此Comparator接口是一个函数式接口.

说了这么多,函数式接口到底有什么作用呢?

Lambda表达式允许你直接以内联的形式为函数式接口的抽象方法提供实现,并把整个表达式作为函数式接口的实例

当我们把一个Lambda表达式赋给一个函数式接口时,这个表达式对应的必定是接口中唯一的抽象方法,因此就不需要以匿名类那么繁琐的形式去实现这个接口.可以说在语法简化上,Lambda表达式完成了方法层面的简化,函数式接口完成了类层面的简化.

Lambda表达式的进一步简化

在Lambda中,除了参数列表的大括号()和箭头不能省略,其他部分如果编译器可以自动推断,都能省略.

简化规则1: 如果编译器可以推断出参数类型,参数列表中就可以省略参数类型

Comparator<Apple> byWeight=
(a1,a2) -> {return a1.getWeight().compareTo(a2.getWeight());}

简化规则2: 如果方法体只有一条语句,花括号{}return(如果有的话)都可以省略

Comparator<Apple> byWeight=
(a1,a2) ->  a1.getWeight().compareTo(a2.getWeight())

简化规则3: 可以通过方法引用来调用方法

首先要介绍一个新概念:方法引用,它的基本思想是:如果一个Lambda代表的只是直接调用这个方法,那最好还是用名称来调用它,而不是去描述如何调用它. 这样可读性更好.

方法引用的一般形式如下

//可以表示对静态/实例方法的调用
类名::方法名
//只能表示实例方法
this::方法名

针对上面的例子,首先利用JDK提供的工具做一些简化

Comparator<Apple> byWeight=
Comparator.comparingInt((a)->a.getWeight())

然后利用方法引用可以简化为如下形式,是不是简单明了?

Comparator<Apple> byWeight= Comparator.comparingInt(Apple::getWeight)

然而Lambda并不是万金油,它也有自己的限制.

Lambda的局部变量限制

Lambda引用局部变量时,要求局部变量时final或effective final(即仅被赋值一次,之后不被修改).实例变量则可以随意使用.这个限制有如下几个原因

Lambda表达式的匹配

鸭子类型: “当看到一只鸟走起来像鸭子、游泳起来像鸭子、叫起来也像鸭子,那么这只鸟就可以被称为鸭子。”

还是以Comparator为例,首先看下Comparator.compare方法签名

int compare(T o1, T o2);

而上面我们提供的Lambda表达式正好符合这个形式: 两个同类型参数,返回int值

(Apple a1,Apple a2) -> {return a1.getWeight().compareTo(a2.getWeight());}

在这里 Lambda表达式就代表"鸭子"这个类型,而Comparator的行为完全符合鸭子的特征("走起来像鸭子,游泳起来像鸭子,叫起来也想鸭子"),就可以认为它"是"一只"鸭子"

假设现在我们有如下接口

public interface SomeClass<T>{
    int someMethod(T a1,T a2);
}

那么上面这个Lambda同样适用于这个方法,因为它也符合鸭子的特征

SomeClass<Apple> someMethod=
(Apple a1,Apple a2) -> {return ...;}

Lambda表达式的匹配规则相当的宽松简单,这也让它的使用更加方便.那么如何有效的利用它呢?

Stream

Java中的Stream是对函数式编程中pipeline的实现,日常业务开发中用的特别特别多,很值得学习.

又一个例子

需求: 有一堆苹果List<Apple> apples,以重量从小到大,获取他们的品种.

以命令式编程来做会是:

Comparator<Apple> byWeight=new Comparator<>(){
    @Override
    public int compareTo(Apple a1,Apple a2) {
        return a1.getWeight().compareTo(a2.getWeight());
    }
}
apples.sort(byWeight);
List<Integer> types=new ArrayList();
for(Apple apple:apples){
    types.add(apple.getType());
}

有了Stream,会是这样,语义清晰了很多,个人非常喜欢这种链式调用(链式调用一时爽,一直链式一直爽)再次展示出命令式编程和函数式编程的不同

List<Integer> types=apples.stream()
                                                    .sorted(Comparator.comparingInt(Apple::getWeight))
                                                    .map(Apple::getType)
                                                    .collect(Collectors.toList()); 

在日常开发中,将一个列表进行排序过滤转化最后收集这个套路十分常见,这个过程中变化的只是我们传递过去的Lambda表达式,这也被称为行为参数化

parallelStream—并行化任务的最简单方式

假设现在有一个包含100w元素的List,要对它进行一系列操作,元素很多,会消耗很多时间.

elements.stream().filter(...).map(...).collect(...);

很明显,多线程执行能够加速执行,只需要一点点修改就能使它以多线程模式执行,wonderful!

elements.parallelStream().filter(...).map(...).collect(...);

parallelStream的底层是fork/join框架.可以把它理解一个智能的线程池,它能将任务拆分并分发给不同的线程执行,最终汇总.然而 parallelStream并不是银弹,以下几点需要注意

  1. parallelStream()的后续操作中进行排序(调用sorted())得出的结果是无效的.原因很简单,它是多线程执行的.这个问题的解决办法就是在parallelStream结束后再进行排序.
  2. 执行的任务不能依赖于线程私有数据(比如ThreadLocal),由于是多线程执行,其他线程并没有当前线程栈上的数据,一个最常见的例子就是在spring中执行数据库操作,session是绑定在线程上的,这时候以parallelStream执行就会报错 can't obtain session
  3. 任务数量必须足够多/单个任务耗时很长(io/网络操作)才有必要使用parallelStream,不然运行反而会更慢. 因为要fork/join也是要付出很大代价的: 划分子任务,分配任务给线程.具体的计算规则可以参考这篇文章

更好的使用Lambda

预定义的函数式接口

java.util.function下有很多JDK预定义的函数式接口.以常用的为例

预定义的FunctionInterface

使用JDK中新增的Lambda相关方法

JDK8的底层机制也添加了很多和函数式编程相关的改进,作为开发者,如何更好的享受这免费的午餐呢?

又又又是一个例子: 对Map<String,Integer> map中的所有值进行+1操作,在JDK8中,最好的做法如下

map.replaceAll((key,oldVal)->oldVal+1);

首先看replaceAll方法的签名,replaceAll接收一个BiFunction作为参数,很明显 这个BiFunction就是我们要传递的行为

/**
     * Replaces each entry's value with the result of invoking the given
     * function on that entry until all entries have been processed or the
     * function throws an exception.  Exceptions thrown by the function are
     * relayed to the caller.
     *
**/
default void replaceAll(BiFunction<? super K, ? super V, ? extends V> function){
    ...
}

再来看BiFunction的定义,它是一个函数式接口,apply方法接收两个参数,有返回值

@FunctionalInterface
public interface BiFunction<T, U, R> {

    /**
     * Applies this function to the given arguments.
     *
     * @param t the first function argument
     * @param u the second function argument
     * @return the function result
     */
    R apply(T t, U u);
}

再来看我们提供的Lambda表达式,符合Map.replaceAll中对BiFunction.apply的方法签名要求.完美!!!

(key,oldVal)->oldVal+1

使用JDK8新增的Lambda相关方法,可以大概遵循下面这个步骤

  1. 查找符合需求的api,如replaceAll
  2. 查看该方法要求的行为(参数)的定义,如BiFucntion.apply()
  3. 编写符合方法签名的Lambda表达式

用Lambda改造设计模式

又是一个例子,使用Lambda来实现模板方法模式,需求如下

根据不同行为对Apple进行不同的处理

public void templateMethod(Supplier<Apple> supplier,Consumer<Apple> consumer){
    ...
    Apple apple=supplier.get();
    ...
    consumer.accept(apple);
    ...
}
//eg. 
Supplier normalSupplier=()->new Apple(10,1);
Consumer<Apple> weightConsumer=(a)->System.out.println(a.getWeight());
templateMethod(normalSupplier,weightConsumer);
//eg. 
Supplier bigSupplier=()->new Apple(100,2);
Consumer<Apple> typeConsumer=(a)->System.out.println(a.getType());
templateMethod(bigSupplier,typeConsumer);

上面这个例子就是行为参数化的直观体现,相比传统的模板方法设计模式,免去了抽象出类的麻烦,更加易用.

总结

Lambda表达式和Stream使Java用起来不再那么繁琐,即使不探究其底层原理,也能用的很舒服.但是只有真正的理解函数式编程的思想,才能真正发挥Lambda表达式的威力.


上一篇 下一篇

猜你喜欢

热点阅读