Java函数式编程设计思想

2020-06-09  本文已影响0人  cbhe

关于

本文介绍了 Java SE 8 引入的 lambda 语言特性以及这些特性背后的设计思想,这些特性包括:

1. 背景

Java 是面向对象的编程语言,它与函数式语言也有共同点,它们的基本元素都可以封装程序行为。Java 通过“类”的概念将变量和方法封装到一起,函数式编程语言通过函数来封装程序行为。但这个共同点也不是那么“共同”,因为“类”通常都比较重量级,一个类的定义中往往又包含多个类、多个字段等。

不过,如果一个 Java 接口中只有一个方法定义,那它就看起来和用起来都非常像在操作一个方法。例如下面这个经典的案例,用户通过提供这个接口的实例来定义处理行为:

public interface ActionListener{
    void actionPerform(ActionEvent e);
}

如果它只在调用出被使用一次,那么程序员一般会使用匿名内部类的方式来实现程序:

button.addActionListener(new ActionListener(){
    public void actionPerform(Action e){
        ui.show(e);
    }
});

许多类库都在大量使用上面的模式。在并行编程时候使用到的“回调函数”概念更是需要将一个方法作为参数传递给另一个方法(Java 中是传入包含回调函数的对象)。但使用以上匿名内部类的书写方法并不是一个非常优雅的做法。因为:

  1. 语法过于冗余
  2. 匿名类中的 this 和变量名容易让人产生误解
  3. 无法使用外部非 final 的变量
  4. 无法对控制流进行抽象

正因为上面这些不优雅的地方,Java 语言才引入了 lambda 表达式。接下来让我们看一下 lambda 表达式是怎么解决这些问题的吧。

2. 函数式接口

Java 中的 lambda 表达式并不是全新引入的概念,可以说它只是匿名内部类的另一种写法。那么为什么我们不真正引入像函数式编程语言一样的函数式 lambda 呢?

尽管匿名内部类看起来非常不优雅而且有种种限制,但它毕竟是 Java 类型体系的标准成员。我们基于匿名内部类来设计 Java 的 lambda 表达式,可以让每个 lambda 表达式都能对应一个接口类型。至少在现在看来,这样的设计是最令人满意的。之所以这么说,是因为:

  1. 接口是 Java 类型系统的一部分
  2. 接口天然拥有其运行时表示(即在JVM中的表示方法)

我们把只有一个抽象方法的接口定义为函数式接口。比如 Runnable 接口就是一个函数式接口,因为它只有一个 run() 方法。(这里强调只有一个 抽象 方法,是因为 Java SE 8 又在接口中新引入了静态方法和默认方法,下面会讲到。)另外,程序员在编程的时候并不需要专门指定一个接口是不是函数式接口,因为编译器会根据该接口是否符合函数式接口的定义来确定它是不是。如果程序员对自己写的代码不太有把握,害怕自己定义的函数式接口会出问题,那么可以使用@FunctionalInterface来修饰这个接口。这时,如果该接口不符合函数式接口的定义,编译器就会报错。

除此之外,我们也应当考虑到,在 Java 这种面相对象的编程语言中如果直接引入函数式编程的元素(比如,不属于任何类或接口的函数)是相当糟糕的。因为,面相对象与面相函数的混杂,使得编程语言本身变得非常臃肿,而且会让程序员感到困惑,不知道该选择哪种编程风格(是使用匿名内部类实现程序行为还是直接使用一个函数)。这样会导致 API 实现风格的分裂。还有一些关键因素促使我们放弃了引入真正函数式编程的念头:

  1. 它会额外增加 Java 类型系统的复杂度
  2. 需要完全重新设计JVM来适应纯函数式编程的需求
  3. 两个函数之间很难进行重载,因为它们不属于任何类,比如 m(X->Y)m(T->R) 无法实现重载

基于以上种种原因,最终我们选择了“使用已有类型”这条路---因为现有的类库大量使用函数式接口,通过沿用这种模式,现有类库可以直接使用 lambda 表达式。例如我们常用的已有的函数式接口:

在 Java SE 8 中还增加了一个新的包 java.util.function,它里面包含了常用的函数式接口,例如:

除了上面的这些类型,我们还为基本数据类型专门提供了函数式接口,比如IntSupplierLongBinaryOperator。另外还提供了带有更多泛型参数的函数式接口,比如BinFunction<T,U,R>它接收一个T和一个U返回R

3. lambda 表达式

匿名类型的编程风格最明显的问题就是语法过于冗余,这也被戏称为“高度问题”。比如前边的ActionListener的例子中,我们写了五行代码,但只有一行在做实际工作。

lambda 表达式是匿名方法,它拥有更加简洁的语法表示,从而解决了“高度问题”。下面是一些 lambda 表达式:

(int x, int y) -> x+y
() -> 42
(String s) -> {System.out.println(s);}

第一个 lambda 表达式接收两个整型参数,并返回它们的和;第二个lambda 表达式不接收参数,返回42;第三个表达式接收一个字符串并将其打印到控制台。

lambda 表达式由参数列表、箭头符号->和函数体组成。函数体既可以是表达式也可以是语句块。

表达式:被执行然后直接返回结果
语句块:按顺序执行每一行,必须显示return返回结果

表达式函数体适合小型 lambda,它消除了return语句使得语法更加简洁。

下面是一些出现在语句中的 lambda 表达式:

FileFilter java = (File f) -> f.getName().endsWith("*.java");

String user = doPrivileged(()->System.getProperty("user.name"));

4. 目标类型

需要注意的是,lambda 表达式中并没有标明它是哪个函数式接口,那么我们如何确定一个 lambda 表达式是什么类型呢?答案是:通过上下文推导得到。例如下面的这两个 lambda 表达式:

Callable<String> c = () -> "done";
PrivilegeAction<String> p = () -> "done";

尽管等号右边都是同一个 lambda 表达式,但第一个被推导为了Callable的实例,而第二个被推导为PrivilegeAction的实例。

编译器负责推导 lambda 表达式的类型,它依据程序上下文所期待的类型来推导。而这个被期待的类型,就是我们所说的目标类型。需要注意的是,lambda 表达式必须出现在目标类型为函数式接口的地方,否则编译器报错。

推导出目标类型(记为T)以后,lambda 表达式能否适用于该目标类型T还需要通过以下条件来判断:

如果 lambda 表达式并未显式写明参数类型,编译器就会为它直接套用目标类型的参数,如果没有出现错误(即推导成功),则也能认为这时 lambda 表达式的参数与T的方法的参数类型一一对应。

Comparator<String> c = (s1, s2)  -> s1.compareToIgnoreCase(s2);

例如上面这个例子,s1s2会被推导成String类型。另外,如果 lambda 表达式的参数只有一个且其类型可以被推导得到,那么就可以省略掉参数列表的括号:

FileFliter java = f -> f.getName.endsWith(".java");

这些语法的简化策略也进一步说明了我们的设计目标:不要让“高度”问题转化为“宽度”问题。我们希望代码尽可能简洁,让代码的读者直接看到代码的核心逻辑,而非纠缠于非必要的地方。

lambda 表达式不是第一个拥有上下文相关类型的 Java 表达式:泛型方法调用和菱形构造器也通过目标类型来进行类型推导:

List<String> ls = Collections.emptyList();
List<Integer> li = Collections.emptyList();

HashMap<String, String> hashMap = new HashMap<>();

5. 目标类型的上下文

之前我们提到,lambda 表达式只能出现在可以通过程序上下文推导出目标类型的地方。下面给出了满足这一条件的地方:

前面三个上下文里,目标类型即是被赋值或被返回的类型:

Comparator<String> c;
c = (s1, s2) -> s1.compareToIgnoreCase(s2);

public Runnable toDoLater() {
    return () -> {System.out.println("to do later")};
}

数组初始化器跟赋值语句类似,只是从“一个”变成了“一串”而已:

FileFilter[] filters 
    = {f -> f.exists(), f -> f.canRead(), f -> f.getName().startsWith("q")};

方法参数的类型推到要相对复杂一些,因为会受到方法重载的影响。重载解析会根据不同的方法签名寻找最合适的方法,但 lambda 表达式由于也需要类型推导才能得到类型信息,所以这里可能会出现互相依赖的问题:一方面 lambda 表达式依赖参数类型来推导自己的类型,另一方面方法重载又依赖入参的类型来寻找最合适的方法。因此,为了避免解析出现二义性,程序员就需要为 lambda 表达式提供更多的类型信息。

例如有以下两个函数式接口:

@FunctionalInterface
interface Ambiguous1{
    int operate(int x, int y);
}

@FunctionalInterface
interface Ambiguous2{
    int operate(int x, int y);
}

且有一个类包括两个重载的方法:

class AmbiguousClass {

    public void toDoLater(Ambiguous1 ambiguous1){
        int x = 10;
        int y = 20;
        System.out.println(ambiguous1.operate(x, y));
    }

    public void toDoLater(Ambiguous2 ambiguous2){
        int x = 1;
        int y = 2;
        System.out.println(ambiguous2.operate(x, y));
    }
}

这时我们如果通过以下方式调用就会出现二义性错误:

AmbiguousClass ambiguousClass = new AmbiguousClass();
ambiguousClass.toDoLater((x, y) -> x+y); // 编译报错 Ambiguous method call

lambda 表达式(x, y) -> x+y并不能通过程序上下文来确定类型,因为它即可能是Ambiguous1也可能是Ambiguous2。这时程序员必须提供更多的类型信息以解决二义性,例如显式指定 lambda 的类型是Ambiguous1:

ambiguousClass.toDoLater((Ambiguous1) (x, y) -> x+y);

lambda 表达式本身也可以为它自己的函数体提供目标类型。也就是要进行两次类型推导,先通过 lambda 所在的上下文推导出它的类型,然后再通过返回值信息推断 lambda 函数体的返回类型,因此我们可以方便地定义一个返回函数的函数:

Supplier<Runnable> supplier = () -> () -> {System.out.println("hi");};

条件表达式?:可以把目标类型“分发”给其子表达式:

Callable<String> c = flag? (() -> 23): (() -> 34);

6. 词法作用域

在内部类中使用变量非常容易出错。内部类自己的变量会覆盖外部类的变量,当内部类有复杂继承链的时候,程序员更难分辨一个变量究竟是外部类的变量还是内部类(继承)的变量。另外,在内部类中直接使用this表示的也是内部类自身而非外部类。

而 lambda 表达式则不会引入新的变量作用域,它完全处于外部类的变量作用域中。lambda 表达式中的任何变量都是外部类的变量,包括 lambda 表达式的参数列表和函数体中的变量。this关键字在 lambda 表达式内外也拥有相同的语义 -- 引用的是外部类。

下面的例子体现了词法作用域的优点,运行程序会将"Hello World!"打印两遍:

class Hello{

    Runnable r1 = () -> System.out.println(this);
    Runnable r2 = () -> toString();

    public String toString(){
        return "Hello World!";
    }

    public static void main(String[] args) {
        new Hello().r1.run();
        new Hello().r2.run();
    }
}

如果将这个例子改为匿名内部类的形式,得到的结果会让90%的程序员感到困惑。如果你觉得自己是10%,那么你可以自己将其改成匿名内部类的形式试一下。

7. 变量捕获

在 Java SE 7 中,内部类只能引用外部类的final变量。Java SE 8 对于内部类和 lambda 表达式引用外部变量的约束稍微宽松了一些,只要一个变量的行为跟final变量一样,即可被内部类和 lambda 引用。也就是说一个变量在初始化后没有被修改过,而且内部类在使用时也不会对它的值进行修改,那么这个变量的行为就跟final变量一样,在变量声明的时候加上final关键字也不会有任何影响。我们称这种变量为有效只读的变量。例如下面这个例子里的hello变量:

    public Callable<String> helloCallable(String name){
        String hello = "hello";
        return () -> (hello+","+name);
    }

另外,Java SE 8 lambda 对于this的引用也与内部类有所区别。除非 lambda 表达式使用this来引用外部类的成员变量或者调用方法,其余情况下 lambda 表达式不会持有对外部对象this的引用。而内部类则不同,它在任何时候都会持有外部实例的引用,这正是内部类总是引起内存泄漏的原因。

8. 方法引用

lambda 表达式是一个匿名的函数,它有参数列表和函数体(和隐式的返回类型)但就是没有方法名。如果这个函数有了自己的名字,那么我们肯定可以根据名字来确定方法的其他部分:返回值、参数列表、方法体。因此,我们可以在需要 lambda 表达式的地方利用方法名引用一个方法来代替 lambda 表达式的匿名函数,这就是方法引用

例如,之前我们提到,可以这样创建对象:

Runnable r = () -> System.out.println("OK");

如果恰好我们已有了一个函数:

public class Test{
    public static void printOK(){
        System.out.println("OK");
    }
}

这时候,我们通过名称将函数引用过来使用,可以达到跟上面一样的初始化效果:

Runnable r = Test::printOK;

9. 方法引用的种类

方法引用有很多种,它们的语法如下:

容易让程序员产生疑惑的是静态方法引用、实例方法引用和类型上的实例方法引用这三者的区别。下面我们来梳理一下:

实例方法引用很容易理解,也就指我们通过方法的名称引用了某个实例的方法:

public class Test{

    private String msg;

    public Test(String msg){
        this.msg = msg;
    }

    public void printOK(){
        System.out.println(this.msg);
    }

    public static void main(String[] args) {

        Test test = new Test("hi");
        Runnable r = test::printOK; // 实例方法引用
    }
}

我们很容易就可以把Runnable r = test::printOK;“翻译成”Runnable r = () -> System.out.println("hi");。因为我们通过方法名printOK找到了参数列表、方法体、返回值信息,来构造出匿名的 lambda 表达式,我们发现构造出的 lambda 表达式的参数列表和方法体与printOK的参数列表和方法体具有一一对应关系。(注意这里“翻译”加了引号,因为左右并不完全对应,msg是变量而不是常量“hi”。这里只是表名我们通过函数名称找到了函数的其他部分。)

但是,使用类型上的实例方法引用就不存在这种一一对应关系了。比如下面的例子:

@FunctionalInterface
interface FuncInterface{
    String shapeShift(String originStr);
}

// 类型上的实例方法引用
FuncInterface funcInterface = String::toUpperCase;

toUpperCase的方法签名是这样的String toUpperCase(),显然参数列表就不能对应出合适的 lambda 表达式。这时String::toUpperCase不再需要参数列表中的参数,而是需要一个调用者。所以这里的 lambda 表达式翻译出来应当长这样s -> s.toUpperCase();

需要注意的是,静态方法引用跟类型上的实例方法引用的语法是一样的。但程序员不必过多担心,因为编译器会根据不同情况来区分到底该以哪种方式解析。

构造函数引用跟静态方法引用非常类似,如果有多个构造函数则会根据所推导出的 lambda 表达式的参数列表去寻找最合适的匹配。例如下面的这段程序:

@FunctionalInterface
interface FuncInterface1{
    Person getPerson();
}

@FunctionalInterface
interface FuncInterface2{
    Person getPerson(String name, int age);
}

class Person{
    
    private String name;
    private int age;
    
    public Person(){}; // 1
    
    public Person(String name, int age){ // 2
        this.name = name;
        this.age = age;
    }
}

public class Test{
    public static void main(String[] args) {
        FuncInterface1 interface1 = Person::new; // 引用的是 1
        FuncInterface2 interface2 = Person::new; // 引用的是 2
    }
}

10. 接口的默认方法和静态方法

函数式接口只能有一个抽象方法,因为一个 lambda 表达式只能表达一个函数,也就只能实现一个函数。但一个接口只有一个方法的话,这显然很浪费。为了解决这个问题,我们为接口引入了全新的方法品种----默认方法。

默认方法提供默认的方法实现,它不属于抽象方法。因此一个函数式接口中新增多少个默认方法都不会让其失去“函数式”特性。默认方法的语法如下所示:

@FunctionalInterface
interface FuncInterface{
    
    void funcMethod();
    
    default String getComment(){
        return "default comment";
    }
}

上面这个例子中的getComment方法就是默认方法,而该接口依然是一个函数式接口。

另外,引入默认方法还有另一个原因。在 Java SE 7 及以前,如果想给一个接口新增方法,那就必须为所有实现类提供该方法的实现,对于应用广泛的接口来说,这是巨大的工作量。但从 Java SE 8 开始,程序员可以以默认方法的形式为接口新增方法,由于不需要子类必须提供实现,所以这种办法为程序员提供了极大的便捷。从此以后,给已有接口增加方法就像呼吸一样自如。

默认方法可以被继承,或者被重新实现,或者重新被置为抽象方法。因此,接口方法拥有了两种类型:抽象方法和默认方法。实现接口的类型通过继承得到默认实现,或像对待抽象方法那样提供一个自己的实现。

接口引入默认方法后,一个新的问题又出现了,多个默认方法都有一段相同功能的代码,显然这段代码是该接口的组成部分,因此我们很自然的会想到将这段多次出现的代码提取出来定义为一个方法。但如果定义成默认方法,这会使接口所表达的意思变得模糊。这时我们又为接口设计了静态方法

静态方法是接口“自有”的方法(注意不是“私有”),它体现了“这段代码是该接口的组成部分”这一含义。下面的例子说明了静态方法的价值:

@FunctionalInterface
interface FuncInterface{

    void funcMethod();

    default String defaultMethod1(){
        String hello = "hello";
        return hello+getSignature();
    }
    
    default String getComment() {
        String comment = "this is a default comment implement";
        return comment + getSignature();
    }
    
    static String getSignature(){
        return " --- from funcInterface";
    }
}

上面这个例子中,两个default方法都需要使用一段签名代码,因此将这段代码提取出来作为static方法以供各处调用。

11. 继承默认方法

Java 类不能继承多个类,这是因为如果能够多继承的话,势必会导致继承体系混乱,类的方法之间互相覆盖,即难使用又难以读懂。而 Java 类可以实现多个接口,是因为 Java SE 7 及之前的接口中只有抽象方法,因此一个类实现多个接口也不必担心实现方法时候的混乱。但我们现在为 Java 的接口增加了默认方法,因此,一个类实现多个接口,也会出现接口默认方法混乱的问题(到底继承的是哪个实现)。所以需要一些规则来解决冲突:

为了演示第二条规则,我们假设CollectionList均提供了default void removeAll()方法,也就是说List的removeAll重写并覆盖了Collection中的该方法。Queue直接继承了CollectionremoveAll方法的默认实现。在下面的implements从句中,List中的方法声明优先于Queue中的方法声明:

class LinkedList<E> implements List<E>, Queue<E>{
    // 省略代码无数
}

这是因为,Collection中的removeAll被重写覆盖了,因此这个版本的默认实现都会被忽略,包括Queue继承而来的。

当两个独立的默认方法或默认方法与抽象方法相互冲突时(不能用第二条规则来确定到底选择哪个默认实现版本),编译器会报错。这时,第一条规则会帮助程序员解决冲突:哪个都不用了,直接在类中定义一个方法。一般来说,程序员会定义一个同名默认方法,并在该方法的实现部分显式指定需要调用哪个父类的默认方法实现版本:

interface Robot implements Artist, Gun{
  default void draw() {Gun.super.drow();}
}

还有一点需要程序员搞清楚:extentsimplements后面类型的出现顺序与它们之间的优先级判定毫无关系。

12. 融会贯通

我们在设计 lambda 时的一个重要目标就是能够让新增的语言特性和现有的 API 库能够无缝衔接。接下来我们通过一个例子来演示这一点,该例子程序的功能是按照姓氏对名字列表进行排序:

List<Person> people = new ArrayList<>();
// 省略 add 代码
Collections.sort(people, new Comparator<Person>() {
    @Override
    public int compare(Person x, Person y) {
        return x.getLastName().compareTo(y.getLastName());
    }
});

冗余的代码太多了,这显然有很严重的“高度问题”。有了 lambda 表达式,我们可以去掉冗余的匿名类:

Collections.sort
 (people, (x, y) -> x.getLastName().compareTo(y.getLastName()));

尽管简洁了很多,但依然不够抽象,程序员依然需要自己亲自实现比较逻辑。好在 StringcompareTo方法,要是基本数据类型的话那就更麻烦了。所以,我们需要借助Comparator中的comparing方法来实现比较逻辑:

Collections.sort
  (people, Comparator.comparing((Person p) -> p.getLastName()));

在类型推导和静态导入的帮助下,我们可以去掉多余的类型信息:

Collections.sort(people, comparing(p -> p.getLastName()));

我们注意到,可以通过类型的实例方法引用来代替匿名的 lambda:

Collections.sort(people, comparing(Person::getLastName));

sort方法放到Collections工具类中并不是一个好的设计方法,这样天然的破坏了 Java 语言的“封装”特性。sort应当是List的一种行为,因此当为接口引入默认方法后,为List接口增加sort方法变得相当容易。改造完成后,代码变成了以下的面貌:

person.sort(comparing(Person::getLastName));

13. 总结

上一篇下一篇

猜你喜欢

热点阅读