Java 8系列之Lambda表达式

概述

使用Lambda表达式也有一段时间了,有时候用的云里雾里的,是该深入学习Java 8新特性的时候了。作为Java最大改变之一的Lambda表达式,其是Stream的使用基础,那就以它开始吧。

这里,我们先明确需要解决的问题:

  1. 什么是闭包?
  2. Lambda表达式如何写?
  3. 什么是函数接口?
  4. 类型推断在Lambda中的体现。

Lambda表达式

lambda表达式的语法由参数列表、->和函数体组成。函数体既可以是一个表达式,也可以是一个语句块:

  • 表达式:表达式会被执行然后返回执行结果。
  • 语句块:语句块中的语句会被依次执行,就像方法中的语句一样——
    • return语句会把控制权交给匿名方法的调用者
    • break和continue只能在循环中使用
  • 如果函数体有返回值,那么函数体内部的每一条路径都必须返回值

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

Lambda表达式的变体

不包含参数且主体为表达式

Lambda表达式不包含参数,使用空括号 ()表示没有参数。

OnClickListener mListener = () -> System.out.println("do on Click");

该Lambda表达式实现了OnClickListener接口,该接口也只有一个doOnClick方法,没有参数,且返回类型为void。

public interface OnClickListener {
    void doOnClick();
}

不包含参数且主体为表达式

该Lambda表达式实现了OnClickListener接口,其主体为一段代码段,在其内用返回或抛出异常来退出。 只有一行代码的Lambda表达式也可使用大括号, 用以明确Lambda表达式从何处开始、到哪里结束。

    OnClickListener mListener_ = () -> {
        System.out.println("插上电源");
        System.out.println("打开电视");
    };

包含一个参数且主体为表达式

Lambda表达式可以包含一个参数,将参数写在()内,如果只有一个参数可以将()省略。

OnItemClickListener mItemListener = position -> System.out.println("position = [" + position + "]");

该Lambda表达式实现了OnItemClickListener接口,该接口也只有一个doItemClickListener方法,其参数为int类型,且返回值为void。

public interface OnItemClickListener {
    void doItemClickListener(int position);
}

包含多个参数且主体为表达式

Lambda表达式可以包含多个参数,将参数写在()内,此时()不可以省略。

IMathListener mPlusListener = (x, y) -> x + y;
int sum = mPlusListener.doMathOperator(10, 5);

该该Lambda表达式实现了IMathListener接口,该接口只有一个doMathOperator方法,其参数为(int, int)类型,且返回值为int类型。

public interface IMathListener {
    int doMathOperator(int start, int plusValue);
}

包含多个参数且主体为代码段

该该Lambda表达式实现了IMathListener接口,该接口只有一个doMathOperator方法,再实现其方法时,创建了一个函数,用来处理结果。

    IMathListener mMaxListener = (x, y) -> {
        if (x > y) {
            return x;
        } else {
            return y;
        }
    };

包含多个参数,指定参数类型且主体为代码段

该该Lambda表达式实现了IMathListener接口,在实现时指定了参数类型,此时,调用时方法时的参数类型是指定的,只能传入相应的类型的参数,若不传入相应参数,编译时会报错。

    IMathListener mSubListener = (int x, int y) -> x - y;

尽管与之前相比, Lambda表达式中的参数需要的样板代码很少,但是Java 8仍然是一种静态类型语言。

引用值, 而不是变量

在使用内部类时,我们总是碰到这种情况,需要引用内部类外面的变量,比如其所在方法内的变量,或者该类的全局变量。当使用方法内的变量时,需要将变量声明为final。此时,将变量声明为final, 意味着不能为其重复赋值,同时在匿名内部,实际上是用的使用赋给该变量的一个特定的值。

final String name = getUserName();
button.addActionListener(new ActionListener() {
    public void actionPerformed(ActionEvent event) {
        System.out.println("hi " + name);
        }
    });

在Java 8中对放松了这限制,在匿名内部,可以其所在方法内的非final变量,但是该变量在既成事实上必须是final,也就是说该变量只能赋值一次。如果再次对其,赋值编译器会报错。

这里写图片描述

这里写图片描述

现在,我们暂且将在匿名内部类内使用的其所在方法内的变量命名为A,不管是在匿名类内部还是在匿名类所在的方法内,再次对A进行赋值时,编译器都会报如下错误,其意思是变量A是在内部类中访问的,需要声明为final或有效的final类型。

Variable ‘plusFinal’ is accessed from within inner class, needs to be final or effectively final

在Lambda表达式中,也是同样的问题,对于其方法体内引用的外部变量,在Lambda表达式所在方法内对变量再次赋值时,编译器会报同样的错误。也就是意味着,换句话说,Lambda表达式引用的是值,而不是变量。

这里写图片描述

这种行为也解释了为什么Lambda表达式也被称为闭包。未赋值的变量与周边环境隔离起来,进而被绑定到一个特定的值。在Java 8中引入了闭包这一概念,并将其使用在了Lambda表达式中。众说纷纭的计算机编程语言圈子里,Java是否拥有真正的闭包一直备受争议,因为在 Java 中只能引用既成事实上的final变量。可以肯定的是,Lambda表达式都是静态类型。

闭包在现在的很多流行的语言中都存在,例如 C++、C# 。闭包允许我们创建函数指针,并把它们作为参数传递。

函数接口

函数式接口是什么呢?函数式接口(Functional Interface)是Java 8对一类特殊类型的接口的称呼。这类接口只定义了唯一的抽象方法的接口(除了隐含的Object对象的公共方法),用作Lambda表达式的类型。

从函数接口的定义可以看出,首先要明确的,其是一个接口,而这个接口呢,有且只有一个抽象的方法,那怎么又和函数结合在一起了呢?

public interface IMathListener {
    int doMathOperator(int start, int plusValue);
}

我们先看一个例子,对于IMathListener接口,这个接口只有一个抽象方法doMathOperator,其接收两个int类型的参数,返回值为int,这个接口可以成为是一个函数接口。当我们声明其对象时,我们可以这样做:

IMathListener mSubListener = (x, y) -> x - y;
mMaxListener.doMathOperator(10, 5));// 其值:5

刚才的声明,就是用Lambda表达式声明了IMathListener的实现,其实现的意义是求两个传入值的差值。这个例子,说明了,函数接口可以通过Lambda表达式来实现。下面来看它是如何和函数扯上关系的。

public class Math {
    public static int doIntPlus(int start, int plusValue) {
        return start + plusValue;
    }
}

现有一个Math类,其内声明了一个静态方法doIntPlus,该方法接收两个int类型的参数,返回值为int,也就是说doIntPlus与IMathListener接口中的doMathOperator方法的签名一样。既然签名一样,我们可以搞些什么事情呢。往下看:

IMathListener mPlusListener = Math::doIntPlus;

我们通过函数调用,直接生成了一个IMathListener对象,这里写法不了解的,后续会做介绍,看下Java 8中的引用。我们还是接着说,通过方法引用来支持Lambda表达式。这样现有函数、接口及Lambda表达式完美的接口在一起。

从前面已经知道,Lambda表达式都是静态类型的,也就是说其在编译时就已经被编译,所以刚才被引用的方法必须是静态的,否则编译器会报错。

>

Non-static method cannot be referenced from a static context

非静态方法不能从静态上下文引用

对于函数接口而言,接口中唯一方法的命名并不重要了,只要方法签名和Lambda表达式的类别相匹配即可。当然了,为了增加代码的易读性,只需对函数接口中为参数起一个代表意义的名字即可。

为了更形象的声明接口,我们可以使用图形来描述不同类型接口。指向函数接口的箭头表示参数, 如果箭头从函数接口射出, 则表示方法的返回类型。若接口没有返回值,没有箭头从函数接口射出。
这里写图片描述

这里,我们应该对函数接口有了清晰的认识。对于一个函数接口而言,其应该有以下特性:

  • 只具有一个方法的接口
  • 其可以被隐式转换为lambda表达式
  • 现有静态方法可以支持lambda表达式
  • 每个用作函数接口的接口都应添加 @FunctionalInterface注释

    @FunctionalInterface
    public interface IMathListener {
        int doMathOperator(int start, int plusValue);
    }
    

    该注释会强制 javac 检查一个接口是否符合函数接口的标准。 如果该注释添加给一个枚举
    类型、 类或另一个注释, 或者接口包含不止一个抽象方法, javac 就会报错。 重构代码时,
    使用它能很容易发现问题。

类型推断

关于类型推断,我们在Java 7中,已经不止一次用到了,可能你一直都没有注意到。比如创建一个ArrayList,我们可以这么做:

    ArrayList<String> mArrayA = new ArrayList<String>();
    ArrayList<String> mArrayB = new ArrayList<>();

在创建mArrayA时,明确指定了ArrayList为String类型,而在创建mArrayB时并未指定ArrayList的类型,编译器是如何知道mArrayB的数据类型呢?在Java 7中,有个神奇的<>操作符,它可使javac推断出泛型参数的类型,这样不用明确声明泛型类型,编译器就可以自己推断出来,这就是它的神奇之处!

对于一个传递的参数,编辑器也可以根据参数的类型来推断具体传入的参数的数据类型。比如有一个方法updateList,其参数为一个String的ArrayList,在调用该方法时,我们传入了一个新建的ArrayList但为指定ArrayList的数据类型,此时编辑器会自行推断传入的ArrayList的数据类型为String,

updateList(new ArrayList<>());

public void updateList(ArrayList<String> values);

Lambda表达式中的类型推断,实际上是Java 7就引入的目标类型推断的扩展。javac根据Lambda 表达式上下文信息就能推断出参数的正确类型。 程序依然要经过类型检查来保证运行的安全性, 但不用再显式声明类型罢了。这就是所谓的类型推断

目标类型是指Lambda表达式所在上下文环境的类型。比如,将 Lambda 表达式赋值给一个局部变量,或传递给一个方法作为参数,局部变量或方法参数的类型就是 Lambda 表达式的目标类型

以之前提到的IMathListener为例,在下面表达式中,javac会自行将x和y推断为int类型.

 IMathListener mSubListener = (x, y) -> x - y;

而在实际开发过程中,为了接口方法的通用性,一般都是使用泛型来指定参数的类型,比如Funtion接口,该接口接收一个F类型的参数并返回一个T类型的值。

这里写图片描述

Function<String, Integer> string2Integer = Integer::valueOf; 

在这个实例中,javac可以推断出接收的数据类型为String,返回类型为Integer。尽管类型推断已经相当智能,但是其也不是无所不能的。在其自行推断前,你需给出其推断的标注。比如下面的例子,javac并不能够推断出Function的具体数据类型:

Function string2Integer = Integer::valueOf; 

上述代码,编译都不会通过,编译器给出的报错信息如下:
Operator ‘& #x002B;’ cannot be applied to java.lang.Object, java.lang.Object.

大家都知道泛型的擦除原则,在编译时,编译器会擦除泛型的具体类型。从而,此时编译器认为参数和返回值都是java.lang.Object实例。这已经偏离了我们的思想,就算编译可以通过,也会造成后续逻辑的混乱,从而不知道该行代码,到底再做什么。在使用泛型时,我们一定会指定泛型的具体的数据类型,以作为编译器的类型推断的标准。

方法重载带来的烦恼

在Java中可以重载方法,造成多个方法有相同的方法名,但签名确不一样,尽管这样让多态性展现的淋漓尽致,但是对于类型推断,带来了不少的烦恼,因为javac可能会推断出多种类型。 这时, javac会挑出最具体的类型。比如方法overloadedMethod中,参数类型不同,返回值相同,这是一个典型的方法重载,在使用具体类型调用时,java可以根据具体类型来判断,此时控制台应打印“String”。

overloadedMethod("abc");

private void overloadedMethod(Object o) {
    System.out.print("Object");
}
private void overloadedMethod(String s) {
    System.out.print("String");
}

如果我们参数传递的是Lambda表达式呢?下面的表达式中,编译器并不知道x和y的数据类型,也并未指定具体的类型,必然造成编译异常。

overloadedMethod((x)->y);

如果在Lambda表达式中指定返回值的数据类型,编译器可以清晰的知道overloadedMethod的参数类型为String类型,根据具体的数据类型,从而调用overloadedMethod(String s) 方法,避免了类型推断不明确的问题。

overloadedMethod((x)->(String)y);

总而言之,Lambda表达式作为参数时,其类型由它的目标类型推导得出,推导过程遵循如下规则:

  • 如果只有一个可能的目标类型,由相应函数接口里的参数类型推导得出;
  • 如果有多个可能的目标类型,由最具体的类型推导得出;
  • 如果有多个可能的目标类型且最具体的类型不明确, 则需人为指定类型。

总结

Lambda是函数式编程的基础,而函数式编程是技术的发展方向。作为一个成熟的Java开发人员,学习新的编程技术那是必须的,也是花时间学习的。

大量的使用Lambda表达式,尽管避免了大量的使用匿名内部类,提高了代码的可读性,可是对猿人们要求更高了,应当对相应的接口或者框架有一定的熟悉程度,否则,看代码就活在云里雾里了。这也是自我相逼提升的一种方式吧。

参考文档

  1. Java中的闭包与回调
  2. 深入理解Java闭包概念
  3. 一见钟情!Java闭包
  4. Java 8 函数式接口
  5. 深入理解Java 8 Lambda(语言篇——lambda,方法引用,目标类型和默认方法)

下一篇:Java 8系列之Stream

发布了174 篇原创文章 · 获赞 274 · 访问量 94万+
展开阅读全文

没有更多推荐了,返回首页

©️2019 CSDN 皮肤主题: 编程工作室 设计师: CSDN官方博客

分享到微信朋友圈

×

扫一扫,手机浏览