Java8-Lambda编程[0] Lambda表达式

最初我接触到Lambda表达式,是用来取代冗长的匿名内部类结构。

例如,要实现一个最简单的线程用来输出当前时间,习惯上可能会有如下写法:

Thread thread = new Thread() {
    @Override
    public void run() {
            while(true) {
                System.out.println(new Date());
            }
        }
    };
    thread.start();
    //若写成匿名形式为:
    // new Thread() {}.start();

也可采用实现Runnable接口的方式,此种方式应用更为广泛:

    Runnable runnable=new Runnable() {
        @Override
        public void run() {
            while(true) {
                System.out.println(new Date());
            }
        }
    };
    Thread thread=new Thread(runnable);
    thread.start();//直接调用run方法会在当前线程内执行run方法的内容而不会开启新的线程
    //若写成匿名形式为
    //new Thread(new Runnable() {public void run(){}}).start();

如果线程的内容比较单调,就像我们上面所举的例子,只是想输出当前时间,这种写法显得相当复杂。我们的关键代码只有一句while(true){System.out.println(new Date());}负责输出时间,而其他的代码算上后括号却占用了一多半的空间。

为此,在Java8中我们可以选择使用Lambda表达式改写上述代码:

Runnable runnable= () -> {
    while(true) {
        System.out.println(new Date());
    }
};
Thread thread=new Thread(runnable);
thread.start();
//若写成匿名形式为
//new Thread(()->{}).start();

这种写法,源自于数学上的λ映射,大致意思是将一组变量映射惟一个函数。在惯用函数式编程的语言中,Lambda表达式是最为常见的代码形式,而Java8对Lambda表达式的引入,为今后Java代码的编程形式注入了新的血液。凡是能使用上述形式的匿名内部类的地方,都可以用Lambda表达式来替换,即凡是对只有一个方法需要实现的接口的实现,均可以写成Lambda表达式的形式。上述的Runnable接口就只有一个run方法需要实现的接口,由于run方法没有参数,所以括号里的参数列表是空的,然后再通过一个->运算符指向后面的大括号,括号里面就是函数内容。这种写法,就好像是把一个函数func(A a){}的名字省略掉,然后拉长成(a)->{}的形式,所以Lambda表达式也被一些人称为匿名函数。

以下是更多的Lambda表达式基础用法实例:

    //为swing组件JButton添加监听事件
    new JButton().addActionListener(e->{});//写法一
    new JButton().addActionListener((e)->{});//写法二
    new JButton().addActionListener((ActionEvent e)->{});//写法三
    new JButton().addActionListener(new ActionListener() {//写法四
        @Override
        public void actionPerformed(ActionEvent e) {
        }
    });
    //实现Integer类型的BinaryOperator接口 对整型变量进行加操作
    BinaryOperator<Integer> add=(x,y)->x+y;//写法一
    BinaryOperator<Integer> add2=(x,y)->{return x+y;};//写法二
    BinaryOperator<Integer> add3=(Integer x,Integer y)->x+y;//写法三
    BinaryOperator<Integer> add4=new BinaryOperator<Integer>() {//写法四
        @Override
        public Integer apply(Integer x, Integer y) {
            return x+y;
        }
    };
    System.out.println(add.apply(1, 2));

先来看第一个例子,写法四是我们最熟悉的匿名内部类写法,而前三种写法均应用了Lambda表达式并有不同程度的省略。写法一与写法二相比,参数e的两边省略了括号,这种写法是被允许的也是十分常见的,但只有在要实现的函数只有一个参数的时候才能省略。而第三种写法相对前两种写法没有省略参数的类型,之所以类型可以省略,是因为javac工具带有类型推断功能,可以自行判断出参数的类型,譬如Java7引入的<>操作符就用在了泛型的推断上,如:

    List<String> stringList=new ArrayList<>();
    List<?>unknownList=stringList;
    stringList.addAll(Arrays.asList("a","b","c","d","e"));
    System.out.println(unknownList.get(0).getClass());

上述代码中List stringList=new ArrayList<>();一句Array后面的的尖括号内省略了泛型的具体类型,而javac会根据前面的List推断出类型为String。同理unknownList中的泛型类型也会被推断stringList的具体类型,所以最后输出的结果是_class java.lang.String_。

值得一提的是,当参数类型不被省略的时候,即使只有一个参数,也需要在两边加上括号。并且,由于Java8在引入Lambda表达式的同时也引入了接口默认方法的新机制,如果省略了参数类型,对个别接口来说可能会导致二义性问题,致使编译器报错。因为有了默认方法,实现接口的时候可以选择性实现或不实现带有默认方法体的方法,如果接口所有方法都有默认方法,就可以选择其中一个方法作为需要实现的方法,使用Lambda表达式来实现。假如一个接口有两个或以上的重载方法,彼此之间只在参数类型上存在区别,并且都实现了默认方法,那么使用Lambda表达式来实现其中一个方法时就必须要表明参数类型,以免造成歧义,令javac不知道要实现的是哪一个方法。

赘述的有些多,接着让我们来看第二个例子,BinaryOperator这个接口可能对没接触过Lambda函数式编程的人有些陌生,它属于java.util.function包,用来定义一种二元操作。这里我们实现了一个Integer类型变量的加法类,写法四仍是我们常用的匿名内部类的写法,实现了BinaryOperator接口唯一需要实现的apply方法,将两个整数相加并返回它们的和。

由于所实现的函数的参数有两个,所以必须要用括号包起来,并用逗号隔开,这和声明一个函数几乎是完全一样的。与写法一相比,写法三表明了参数的类型,要注意这里的Integer是不能写成int的,装箱与拆箱只在表达式中可以自动执行,在声明类型的时候是不可以混用基本类型与包装类的。

写法一相对于写法二,省略了大括号与return关键字以及后面的分号,这种写法适用于函数内容只有一条返回语句的情况。例如本例中的apply函数在写法二与四中直接返回了x+y的值,而写法一与三则采用了省略的写法->运算符后面直接跟了表达式x+y。那么问题来了,如果函数返回值为void,而我们又想要举一个省略了{return;}写法的lambda表达式的例子,该怎么办呢?办法其实也是有的,只要我们能找到一个运算结果为void的表达式即可,但是Void类并不能实例化,也产生不了Void对象,我于是只好采用了函数嵌套返回的办法,代码如下:

int[] a={1,2,3};
new JButton().addActionListener(e->Arrays.sort(a));

本例中我选择了_java.util._包中的Arrays工具类的静态方法sort来对一个数组进行排序,该方法返回值为void,正中了我们的下怀。上面的语句完全可以通过执行,至于Java内部对void是如何处理的,我们现在暂不深究。这里值得注意的是sort方法的实际参数a,a是一个在Lambda表达式(或者叫匿名函数)之外声明的数组,是一个局部变量。根据我们的经验,匿名内部类外部的局部变量在匿名内部类内部的函数中一般是不能访问的,除非将其声明为final即终值变量(或译为不可变变量,但怎么听都别扭)。而Java8放宽了限定,要求要被访问的局部变量应为事实终值(effectively final)的变量,这个概念我第一次看就没明白,看了代码才明白意义。说白了就是该变量虽然没有被声明为final,但是被赋初值之后就不能再更改它的值。

Lambda表达式,以及它所替代的匿名内部类函数所使用的其实并不是变量本身,而仅仅只是将变量的值作为一个常量传给了函数,函数实际上使用的是一个固定的值或者引用。那么有没有什么办法能让内部的函数访问一个可以改变值的外部局部变量呢,这里我想了一个投机取巧的办法。依招前面说的内部函数可以使用一个固定的引用,那么该引用引用的类虽然固定,但是类的各个成员均是可变的,所以我们可以选择将局部变量设成一个内部类的成员来进行访问。但是这样做给人感觉很麻烦,逻辑上也奇奇怪怪的,局部变量也不再是真正的局部变量了,于是我想到了一个像对象又不太像的东西——数组。将局部变量放入数组中,在对数组成员进行访问,代码会清爽很多:

    double[] a=new double[6];
    a[5]=Math.random();
    Predicate<Double> gta5=i->i>a[5];
    System.out.println(gta5.test(5.0));

上述代码使用一个与BinaryOperator接口似的一个泛型接口Predicate,他们同属于_java.util.function_包,功能也类似,Predicate接口实现后用于判断一个变量是否符合规定,而规定内容可以利用Lambda表达式传入。上例中我实现了一个双精度浮点数是否大于Math.random函数产生的随机数,如果成立则返回true,否则返回false。我测试时a[5]=0.19733399898632598,所以返回值为true。当然这不是重点,变量名gta5也不是重点,重点是我们将一个局部变量(姑且算是吧)放到了数组里,并通过这种方式,使其在Lambda表达式中成功的被访问了,就是这样。当然这里还有一个问题就是这个a[5]还算是局部变量吗,这个事情我也说不清。在和Java除了名字就没多大关系的JavaScript语言中,数组和对象是差不多的东西,js的数组可以集数组和对象为一身,数组的成员就是用数字命名的对象成员,这个大家可以自己去学一学体味一下。而java中的数组算不算对象想来有些争议,比较权威的说法是数组类型不是类而数组实例却是对象,不知大家怎么看,是否有种“万物皆对象"的感觉呢?

最后让我们来介绍一下方法引用,一种更上一层楼的简化写法。前面的代码中,经常会出现形如foo->foo.method();的写法,如果一种代码形式经常被使用甚至成为一种代码模板,那么就有必要对这种写法中重复的部分进行简化。上述代码就可以简化为Foo::method;的形式。注意method的后面没有加括号,因为我们不是在调用这个方法,而是引用了这个方法的名字。方法引用可以分为四种,如下表:

Lambda表达式方法引用类型特点
arg->arg.method();Arg::method;单一参数作为方法调用者,调用对象方法
(arg...)->Kit.staticMethod(arg...);Kit::staticMethod参数作为方法参数,调用静态方法
(first,arg...)->first.method(arg...);First::method首参数作为调用者,其余参数作为方法参数
(arg...)->new Arg(arg...) ;Arg::new参数作为构造方法参数,实例化一个新对象

下面是一个简单的例子,定义一个二元操作,将两个字符串连接到一起:

public class Main {
    private String s;
    public Main(String s1,String s2){s=s1.concat(s2);}
    public String toString(){return s;}
    public static String test(String s1,String s2){return s1.concat(s2);}
    public static void main(String[] args) {
        BinaryOperator<String> b2=String::concat;
        BinaryOperator<String> b3=Main::test;
        BiFunction<String,String,Main> b4=Main::new;
        System.out.println(b2.apply("a", "b"));
        System.out.println(b3.apply("a", "b"));
        System.out.println(b4.apply("a", "b"));
    }
}

这里我故意把前几个辅助的函数写成一行,就是为了营造一种Lambda的感觉。第一种方法引用类型其实是第三种方法引用类型的一种特殊情形,所以没有再举例。上面代码的b1,b2,b3分别使用了后三种类型的方法引用,这里就不再赘述。