首页 › 程序设计 › java

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

zjb77553 / 文 发表于2017-12-12 23:16 次阅读 java8,lambda

缘起

  最初我接触到Lambda表达式,是用来取代冗长的匿名内部类结构。例如,要实现一个最简单的线程用来输出当前时间,习惯上可能会有如下两种写法。

例0.0:
    //重写Thread类
    new Thread() {
        @Override
        public void run() {
            while(true) {
                System.out.println(new Date());
            }
        }
    }.start();
    //重写Runable接口
    new Thread(new Runnable() {
        @Override
        public void run() {
            while(true) {
                System.out.println(new Date());
            }
        }
    }).start();

  如果线程的内容比较单调,就像我们上面所举的例子,只是想输出当前时间,这种写法显得相当复杂。我们的关键代码只有一句while(true){System.out.println(new Date());},而其他的代码算上后括号却占用了一多半的空间,这种代码被称为样板代码,看起来既死板又不美观,在阅读的时候大大浪费我们宝贵的时间与精力。为此,在Java8中我们可以选择使用Lambda表达式改写上述代码。

例0.1:
    new Thread(()->{
        while(true)
            System.out.println(new Date());
    }).start();

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

删繁就简 能省则省

  下面是更多的Lambda用法示例。

例0.2:
    //为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的两边省略了括号,这种写法是被允许的也是十分常见的,但只有在表达式恰好只有一个参数的时候才能省略。

  写法一相对于写法二,省略了大括号与return关键字以及后面的分号,这种写法适用于函数内容只有一条返回语句的情况。例如本例中的apply函数,在写法二与写法四中直接返回了x+y的值,而写法一与三则采用了省略的写法,->运算符后面直接跟了表达式x+y。如果函数返回值为void,除了可以写成()->{}形式,也可以直接返回一个无运算结果的表达式或返回值为为void的函数。

类型推断与默认方法

  与前两种写法相比,写法三标明了参数的类型。要注意这里的Integer是不能写成int的,装箱与拆箱只在表达式中可以自动执行,在声明类型的时候是不可以混用基本类型与包装类的。之所以类型可以省略,是因为javac工具带有类型推断功能,可以自行判断出参数的类型,譬如Java7引入的<>操作符就用在了泛型的推断上。

例0.3:
    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的具体类型,而javac在编译时会进行类型擦除,根据前面的List推断出后面的new List<>类型为String。同理unknownList中的位置类型<?>也会被推断stringList的具体类型,所以最后输出的结果是class java.lang.String。值得一提的是,当参数类型不被省略的时候,即使只有一个参数,也需要在两边加上括号,将他们包在一起。

  Java8在引入Lambda表达式的同时,也引入了接口默认方法的新机制,主要是为了解决后向兼容的问题,在接口的抽象方法前面加上defaut关键字,就可以给它一个默认实现。在其他类实现接口的时候,可以选择不实现有默认方法的方法而沿用默认方法,这样使得接口看起来就像是一个抽象类。若是一个接口所有方法都有默认方法,就可以选择其中一个方法作为需要实现的方法,使用Lambda表达式来实现。但如果一个接口有两个或以上的重载方法,彼此之间只在参数类型上存在区别,并且都实现了默认方法,那么使用Lambda表达式来实现其中一个方法时就必须要表明参数类型,以免造成歧义,无法进行类型推断。但是只有返回值不同,函数签名其他部分相同的两个函数却不会产生二义性,这一点和函数重载不同,我们可以通过Callable与Runnable两个接口在ExecutorService的submit方法的成功调用得出结论。

例0.4:
    ExecutorService executorService = Executors.newCachedThreadPool();
    executorService.submit(()->{});
    //无返回值 转化为new Runnable(){public void run(){}}
    Future<String> future = executorService.submit(()->"result");
    //有返回值 转化为new Callable<String>(){public String call(){return "result";}}

多元Lambda表达式

  接下来让我们再仔细研究一下第二个例子,它的每种写法都带有两个参数,而Lambda表达式的参数个数和普通函数一样是没有限制的。BinaryOperator这个接口可能对没接触过Lambda函数式编程的人有些陌生,它属于java.util.function包,用来定义一种二元操作。这里我们实现了一个Integer类型变量的加法类,写法四仍是我们常用的匿名内部类的写法,实现了BinaryOperator接口唯一需要实现的apply方法,将两个整数相加并返回它们的和。由于所实现的函数的参数有两个,所以必须要用括号包起来,并用逗号隔开,这和声明一个函数几乎是完全一样的。

必须从一而终的局部变量

  观察下面的代码。

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

  上述代码中的Lambda表达式中调用了Arrays的sort方法,来对数组a进行排序。其中实际参数a是一个局部变量,而根据我们的经验,匿名内部类外部的局部变量在匿名内部类内部的函数中一般是不能访问的,除非将其声明为final,即终值变量(或译为不可变变量,但怎么听都别扭)。而Java8放宽了限定,要求要被访问的局部变量应为事实终值(effectively final)的变量,说白了就是该变量虽然没有被声明为final,但是被赋初值之后只要不再更改它的值就可以。Lambda表达式,以及它所替代的匿名内部类函数所使用的其实并不是变量本身,而仅仅只是将变量的值作为一个常量传给了函数,函数实际上使用的是一个固定的值或者引用。这一点可以通过查看编译后的字节码得知,我们所传入的局部变量会成为匿名内部类的一个字段,并且在构造函数中为其赋值,如果不设成final,在该局部变量引用其他对象的时候,内部类中的相应字段将与该变量引用不同对象,最终必然导致逻辑出错,内部类中的局部变量与外部的局部变量同名却不同值。

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

例0.6:
    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接口用于判断一个变量是否符合规定,而判断条件可以通过传入一个结果为boolean类型的Lambda表达式来确定。上例中我实现了一个双精度浮点数是否大于Math.random函数产生的随机数,如果成立则返回true,否则返回false。我测试时a[5]=0.19733399898632598,所以返回值为true。当然这不是重点,变量名gta5也不是重点,重点是我们将一个局部变量(姑且算是吧)放到了数组里,并通过这种方式,使其在Lambda表达式中成功的被访问了。在和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参数作为构造方法参数,实例化一个新对象

  下面是一个简单的例子,通过方法引用的形式,定义一个二元操作,将两个字符串连接到一起。

例0.7:
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分别使用了后三种类型的方法引用,这里就不再赘述。

收藏 赞 (0) 踩 (0)