深入浅出 Java 8 Lambda 表达式和函数式接口

1、为什么 Java 需要 Lambda 表达式?

Java是一门的面向对象语言,除了部分简单数据类型,Java 中的一切都是对象,即使数组也是一种对象。在 Java 中定义的函数或方法不可能完全独立,也不能将方法作为参数或返回一个方法给实例。

在Swing编程中,我们总是通过匿名类给方法传递函数功能,以下是旧版的事件监听代码:

someObject.addMouseListener(new MouseAdapter() {
        public void mouseClicked(MouseEvent e) {

            //Event listener implementation goes here...

        }
});

在上面的例子里,为了给 Mouse 监听器添加自定义代码,我们定义了一个匿名内部类 MouseAdapter 并创建了它的对象,通过这种方式,我们将一些函数功能传给 addMouseListener 方法。

简而言之,在 Java 里将普通的方法或函数像参数一样传值并不简单,为此,Java 8 增加了一个语言级的新特性,名为 Lambda 表达式。因此上面的代码就可以简化:

someObject.addMouseListener((e)->{
    //Event listener implementation goes here...
});

在 Steve Yegge 辛辣又幽默的博客文章里,描绘了 Java 世界是如何严格地以名词为中心的,如果你还没看过,赶紧去读吧,写得非常风趣幽默,而且恰如其分地解释了为什么 Java 要引进 Lambda 表达式。

Lambda 表达式为 Java 添加了缺失的函数式编程特点,使我们能将函数当做一等公民看待。尽管不完全正确,我们很快就会见识到 Lambda 与闭包的不同之处,但是又无限地接近闭包。在支持一类函数的语言中,Lambda 表达式的类型将是函数。但是,在 Java 中,Lambda 表达式是对象,他们必须依附于一类特别的对象类型——函数式接口(functional interface)。

2、Lambda表达式简介

Lambda 表达式是一种匿名函数,简单地说,它是没有声明的方法,也没有访问修饰符、返回值声明和名字。

你可以将其想做一种速记,在你需要使用某个方法的地方写上它。当某个方法只使用一次,而且定义很简短,使用这种速记替代之尤其有效,这样,你就不必在类中费力写声明与方法了。

(1)Lambda表达式的结构

Lambda表达式是java8中提供的一种新的特性,它支持Java也能进行简单的“函数式编程”。 它是一个匿名函数,Lambda表达式基于数学中的λ演算得名,直接对应于其中的lambda抽象(lambda abstraction),是一个匿名函数,即没有函数名的函数。java8中lambda表达式有三部分组成:

  • 第一部分为一个括号内用逗号分隔的形式参数,参数是函数式接口里面方法的参数
  • 第二部分为一个箭头符号:->
  • 第三部分为方法体,可以是表达式和代码块

语法如下:

()->{expression}
//或
(param)->{expression}

以下是一些lambda表达式的例子:

(int a, int b) -> {  return a + b; }

() -> System.out.println("Hello World");

(String s) -> { System.out.println(s); }

() -> 42

() -> { return 3.1415 };

关于lambda表达式的语法需要特别注意以下特性:

  • 一个 Lambda 表达式可以有零个或多个参数
  • 参数的类型既可以明确声明,也可以根据上下文来推断。例如:(int a)(a)效果相同
  • 所有参数需包含在圆括号内,参数之间用逗号相隔。例如:(a, b)(int a, int b)(String a, int b, float c)
  • 空圆括号代表参数集为空。例如:() -> 42
  • 当只有一个参数,且其类型可推导时,圆括号()可省略。例如:a -> return a*a
  • Lambda 表达式的主体可包含零条或多条语句
  • 如果 Lambda 表达式的主体只有一条语句,花括号{}可省略。匿名函数的返回类型与该主体表达式一致
  • 如果 Lambda 表达式的主体包含一条以上语句,则表达式必须包含在花括号{}中(形成代码块)。匿名函数的返回类型与代码块的返回类型一致,若没有返回则为空

3、Lambda表达式的函数式接口

java8的lambda表达式实质是是以匿名内部类的形式的实现的。看下面代码。代码中我们定义了一个叫opt的Lambda表达式,看返回值它是一个IntBinaryOperator实例。

public static void main(String[] args) {
    //本质就是实现了IntBinaryOperator这个接口的唯一的一个方法applyAsInt,然后返回了一个对象实例
    IntBinaryOperator opt = (a, b) -> a * b;
    //调用他的方法applyAsInt计算结果
    int ans = opt.applyAsInt(5, 3);         
    System.out.println(ans);     //15       
}                                           

那么你有想过为什么匿名内部类可以这个简写吗?我们来看看IntBinaryOperator的定义:

从源码可以看到,IntBinaryOperator这个接口被一个叫**@FuncationalInterface的注解修饰了,并且在这个解口中只有一个抽象方法,在Java8以后我们把使用@FunctionalInterface标注了的并且只有一个抽象方法的接口称作这是一个函数式接口**。

同样地,java.lang.Runnable也是一个函数式接口,在 Runnable 接口中只声明了一个方法void run(),我们使用匿名内部类来实例化函数式接口的对象,有了 Lambda 表达式,这一方式可以得到简化。下图所示是Runnable接口的定义。

了解到这里,我觉得我们应该对函数式接口有个更深层次的认识。

函数式接口(Functional Interface)是JAVA 8对一类特殊类型的接口的称呼。这类接口只定义了唯一的抽象方法的接口(除了隐含的Object对象的公共方法,因此最开始也就做SAM类型的接口(Single Abstract Method)。定义函数式接口的原因是在Java Lambda的实现中,开发组不想再为Lambda表达式单独定义一种特殊的Structural函数类型,称之为箭头类型(arrow type,依然想采用Java既有的类型(class, interface, method等).原因是增加一个结构化的函数类型会增加函数类型的复杂性,破坏既有的Java类型,并对成千上万的Java类库造成严重的影响。权衡利弊,因此最终还是利用SAM 接口作为 Lambda表达式的目标类型.另外对于函数式接口来说@FunctionalInterface并不是必须的,只要接口中只定义了唯一的抽象方法的接口那它就是一个实质上的函数式接口,就可以用来实现Lambda表达式。

常用的函数式接口

在java 8中已经为我们定义了很多常用的函数式接口它们都放在java.util.function包下面,一般有以下常用的四大核心接口:

函数式接口 参数类型 返回类型 用途
Consumer(消费型接口) T void 对类型为T的对象应用操作。void accept(T t)
Supplier(供给型接口) T 返回类型为T的对象。 T get();
Function(函数型接口) T R 对类型为T的对象应用操作并返回R类型的对象。R apply(T t);
Predicate(断言型接口) T boolean 确定类型为T的对象是否满足约束。boolean test(T t);

接下来我们来详细了解一下每个接口的作用。

Consumer 消费型接口

**消费型接口就是有输入没有返回的接口,有进无出所以叫消费者Consumer **,如果要定义一个有参的无返回值的抽象方法的接口时,可以直接使用Consumer

可以看到在Consumer接口中有两个方法,但是只有一个accept抽象方法并且使用@FunctionalInterface修饰了,因此他符合函数式接口的定义。其中accept方法用于指定一个消费者的消费行为的,需要传入一个参数,参数的类型由泛型决定,默认方法andThen作用是合并2个消费者生成一个新的消费者,先执行第一个消费者的accept方法,再执行第二个消费者的accept方法 

public class ConsumerTest {

    public static void main(String[] args) {
          test((integer)->{
              System.out.println(integer);
          });
    }

    public static void test(Consumer<Integer> opt){
        for(int i=0;i<10;i++) {
            opt.accept(i);
        }
    }

}
Supplier 生产者接口

生产者接口无需传递参数但是有返回值,他返回一个泛型参数指定类型的对象实例,无进有出所以叫生产者或提供者Supplier。如果要定义一个无参的有Object返回值的抽象方法的接口时,可以直接使用Supplier,不用自己定义接口了。

废话不多说,直接上Demo

public class SupplierTest {

    public static void main(String[] args) {
        String res = test(() -> {
            StringBuilder sb = new StringBuilder();
            for (int i = 0; i < 1000; i++) {
                if (i % 2 == 0) {
                    sb.append(i);
                }
            }
            return sb.toString();
        });
        System.out.println(res);
    }

    //使用Supplier接口可以返回一个指定类型的对象
    public static String test(Supplier<String> supplier) {
        return supplier.get();
    }

}
Function 函数型接口

Function 接口用来根据一个类型的数据得到另一个类型的数据,前者称为前置条件,后者称为后置条件。有进有出,所以称为“函数Function”。

该接口可以理解成一个数据工厂,用来进行数据转换,将一种数据类型的数据转换成另一种数据. 泛型参数T:要被转换的数据类型(原料),泛型参数R:想要装换成的数据类型(产品)。

public class FactionTest {

    public static void main(String[] args) {
        String info = "小明,20,男";
        User user = User.getInstance(info,
                (str) -> {
                    String[] strings = str.split(",");
                    if (strings == null || strings.length == 0) {
                        return null;
                    }
                    return new User(strings[0], Integer.parseInt(strings[1]), strings[2]);
                });
        System.out.println(user);
    }


}

class User {

    private String name;
    private Integer age;
    private String gender;

    public User(String name, Integer age, String gender) {
        this.name = name;
        this.age = age;
        this.gender = gender;
    }


    //实现三个数据转换 String->String, String->Integer,Integer->Integer
    public static User getInstance(String info,
                                   Function<String, User> f1) {
        if (info == null) {
            return null;
        }
        return f1.apply(info);
    }


    @Override
    public String toString() {
        return "User{" +
                "name='" + name + '\'' +
                ", age=" + age +
                ", gender='" + gender + '\'' +
                '}';
    }
}
Predicate 断言接口

Predicate接口主要是对某种类型的数据进行判断,返回一个boolean型结果。可以理解成用来对数据进行筛选。

当需要定义一个有参并且返回值是boolean型的方法时,可以直接使用Predicate接口中的抽象方法

//1.必须为女生;
//2. 姓名为4个字。
public class PredicateTest {
    public static void main(String[] args) {
        String[] array = {"迪丽热巴,女", "古力娜扎,女", "马尔扎哈,男", "赵丽颖,女"};
        List<String> list = filter(array,
                (str) -> {
                    return "女".equals(str.split(",")[1]);
                },
                (str) -> {
                    return str.split(",")[0].length() == 3;
                });
        System.out.println(list);
    }

    private static List<String> filter(String[] array, Predicate<String> one, Predicate<String> two) {
        List<String> list = new ArrayList<>();
        for (String info : array) {
            //and相当于与操作
            if (one.and(two).test(info)) {
                list.add(info);
            }
        }
        return list;
    }
}

4、Lambda表达式中的Stream

什么是Stream?

官方解释:

简单来讲,stream就是JAVA8提供给我们的对于元素集合统一、快速、并行操作的一种方式。 它能充分运用多核的优势,以及配合lambda表达式、链式结构对集合等进行许多有用的操作。

Stream 就如同一个迭代器(Iterator),单向,不可往复,数据只能遍历一次,遍历过一次后即用尽了,就好比流水从面前流过,一去不复返。而和迭代器又不同的是,Stream 可以并行化操作,迭代器只能命令式地、串行化操作。顾名思义,当使用串行方式去遍历时,每个 item 读完后再读下一个 item。而使用并行去遍历时,数据会被分成多个段,其中每一个都在不同的线程中处理,然后将结果一起输出。Stream 的并行操作依赖于 Java7 中引入的 Fork/Join 框架(JSR166y)来拆分任务和加速处理过程

Stream也有很多种创建方式,常见的创建方式有:

(1)由值创建流:

Stream<String> stream = Stream.of("Java 8 ", "Lambdas ", "In ", "Action");

(2) 由数组创建流:

int[] numbers = {2, 3, 5, 7, 11, 13}; 
int sum = Arrays.stream(numbers).sum();    

这里也可以使用集合创建流

 ArrayList<Integer> list = Lists.newArrayList(3, 4, 5, 6, 7, 9, 10);
 list.stream().map((n) -> {                                         
     if (n % 2 == 0) {                                              
         return n + 1;                                              
     } else {                                                       
         return n + 2;                                              
     }                                                              
 }).forEach((obj) -> {                                              
     System.out.println(obj);                                       
 });                                                                

(3)由文件创建流

Stream<String> lines =Files.lines(Paths.get("data.txt"), Charset.defaultCharset())

(4)上面的这些Stream都是有限的,我们可以用函数来创建一个无限Stream

Stream.iterate(0, n -> n + 2).forEach(System.out::println);

Stream也很懒惰,它只会在你真正需要数据的时候才会把数据给传给你,在你不需要时它一个数据都不会产生。

5、Lambda表达式使用指南

(1)保持Lambda表达式简短和一目了然

长长的Lambda表达式通常是危险的,因为代码越长越难以读懂,意图看起来也不明,并且代码也难以复用,测试难度也大。

values.stream()
  .mapToInt(e -> {    
    int sum = 0;
    for(int i = 1; i <= e; i++) {
      if(e % i == 0) {
        sum += i;
      }
    }  
    return sum;
  })
  .sum());  //代码复杂难懂 

//------------------------------------------------------
values.stream()
  .mapToInt(e -> sumOfFactors(e))
  .sum(); //代码简洁一目了然
    
int sumOfFactors(int e){
    int sum = 0;
    for(int i = 1; i <= e; i++) {
      if(e % i == 0) {
        sum += i;
      }
    }  
    return sum;
}
(2)使用@FunctionalInterface 注解

如果你确定了某个interface是用于Lambda表达式,请一定要加上*@FunctionalInterface,表明你的意图。*不然将来说不定某个不知情的家伙比如你旁边的好基友,在这个interface上面加了另外一个抽像方法时,你的代码就悲剧了。

(3)优先使用java.util.function包下面的函数式接口

java.util.function 这个包下面提供了大量的功能性接口,可以满足大多数开发人员为lambda表达式和方法引用提供目标类型的需求。每个接口都是通用的和抽象的,使它们易于适应几乎任何lambda表达式。开发人员应该在创建新的功能接口之前研究这个包,避免重复定义接口。另外一点就是,里面的接口不会被别人修改~。

(4)不要在Lambda表达中执行有"副作用"的操作

**"**副作用"是严重违背函数式编程的设计原则,在工作中我经常看到有人在forEach操作里面操作外面的某个List或者设置某个Map这其实是不对的。

(5)不要把Lambda表达式和匿名内部类同等对待

虽然我们可以用匿名内部类来实现Lambda表达式,也可以用Lambda表达式来替换内部类,但并不代表这两者是等价的。这两者在某一个重要概念是不同的:this指代的上下文是不一样的。当您使用内部类时,它将创建一个新的范围。通过实例化具有相同名称的新局部变量,可以从封闭范围覆盖局部变量。您还可以在内部类中使用这个关键字作为它实例的引用。但是,lambda表达式可以使用封闭范围。您不能在lambda的主体内覆盖范围内的变量

(6)多使用方法引用

在Lambda表达式中 a -> a.toLowerCase()和String::toLowerCase都能起到相同的作用,但两者相比,后者通常可读性更高并且代码会简短。

(7)尽量避免在Lambda的方法体中使用{}代码块

优先使用

Foo foo = parameter -> buildString(parameter);

private String buildString(String parameter) {
    String result = "Something " + parameter;
    //many lines of code
    return result;
}

而不是

Foo foo = parameter -> { String result = "Something " + parameter;
    //many lines of code
    return result;
};
(8)不要盲目的开启并行流

Lambda的并行流虽好,但也要注意使用场景。如果平常的业务处理比如过滤,提取数据,没有涉及特别大的数据和耗时操作,则真的不需要开启并行流。因为多行线程的开启和同步这些花费的时间往往比你真实的处理时间要多很多。但一些耗时的操作比如I/O访问,DB查询,远程调用,这些如果可以并行的话,则开启并行流是可提升很大性能的。因为并行流的底层原理是fork/join,如果你的数据分块不是很好切分,也不建议开启并行流。举个例子ArrayList的Stream可以开启并行流,而LinkedList则不建议,因为LinkedList每次做数据切分要遍历整个链表,这本身就已经很浪费性能,而ArrayList则不会。

参考资料

留言区

还能输入500个字符