深入探讨 Lambda 表达式(下)

说明:

由于 Lambda 表达式涉及的周边知识点实在太多,因此拆分为上、下两篇文章讲解。

本篇为下篇,上篇请点击:深入探讨 Lambda 表达式(上)

目录介绍:
深入探讨 Lambda 表达式(下)

在上篇 “深入探讨 Lambda 表达式(上)” 中,主要讲述了 1~4 章节,本篇,主要介绍 5~8 章节。

5. 与匿名类的区别

在一定程度上,Lambda 表达式是对匿名内部类的一种替代,避免了冗余丑陋的代码风格,但又不能完全取而代之。

我们知道,Lambda 表达式简化的是符合函数式接口定义的匿名内部类,如果一个接口有多个抽象方法,那这种接口不是函数式接口,也无法使用 Lambda 表达式来替换。

举个示例:

public interface DataOperate {
    public boolean accept(Integer value);

    public Integer convertValue(Integer value);
}

public static List<Integer> process(List<Integer> valueList, DataOperate operate) {
    return valueList.stream()
        .filter(value -> operate.accept(value))
        .map(value -> operate.convertValue(value))
        .collect(Collectors.toList());
}

public static void main(String[] args) {
    List<Integer> valueList = Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9);

    // 示例场景1: 将大于3的值翻倍,否则丢弃,得到新数组
    List<Integer> newValueList1 = process(valueList, new DataOperate() {
        @Override
        public boolean accept(Integer value) {
            return value > 3 ? true : false;
        }

        @Override
        public Integer convertValue(Integer value) {
            return value * 2;
        }
    });

    // 示例场景2:将为偶数的值除以2,否则丢弃,得到新数组
    List<Integer> newValueList2 = process(valueList, new DataOperate() {
        @Override
        public boolean accept(Integer value) {
            return value % 2 == 0 ? true : false;
        }

        @Override
        public Integer convertValue(Integer value) {
            return value / 2;
        }
    });
}

上面示例中的 DataOperate 接口,因存在两个接口,是无法使用 Lambda 表达式的,只能在调用的地方通过匿名内部类来实现。

若  DataOperate  接口多种不同的应用场景,要么使用匿名内部类来实现,要么就优雅一些,使用设计模式中的策略模式来封装一下,Lambda 在这里是不适用的。

6. 变量作用域

不少人在使用 Lambda 表达式的尝鲜阶段,可能都遇到过一个错误提示:

Variable used in lambda expression should be final or effectively final

以上报错,就涉及到外部变量在 Labmda 表达式中的作用域,且有以下几个语法规则。

6.1 变量作用域的规则

  • 规则 1:局部变量不可变,域变量或静态变量是可变的

何为局部变量?局部变量是指在我们普通的方法内部,且在 Lambda 表达式外部声明的变量。

在  Lambda 表达式内使用局部变量时,该局部变量必须是不可变的。

如下的代码展示中,变量 a  就是一个局部变量,因在 Lambda 表达式中调用且改变了值,在编译期就会报错:

public class AClass {
    private Integer num1 = 1;
    private static Integer num2 = 10;

    public void testA() {
        int a = 1;
        int b = 2;
        int c = 3;
        a++;
        new Thread(() -> {
            System.out.println("a=" + a); // 在 Lambda 表达式使用前有改动,编译报错
            b++; // 在 Lambda 表达式中更改,报错
            System.out.println("c=" + c); // 在 Lambda 表达式使用之后有改动,编译报错

            System.out.println("num1=" + this.num1++); // 对象变量,或叫域变量,编译通过
            AClass.num2 = AClass.num2 + 1;
            System.out.println("num2=" + AClass.num2); // 静态变量,编译通过
        }).start();
        c++;
    }
}

上面的代码中,变量 abc 都是局部变量,无论在 Lambda 表达式前、表达式中或表达式后修改,都是不允许的,直接编译报错。而对于域变量 num1,以及静态变量 num2,不受此规则限制。

  • 规则 2:表达式内的变量名不能与局部变量重名,域变量和静态变量不受限制

不解释,看代码示例:

public class AClass {
    private Integer num1 = 1;
    private static Integer num2 = 10;

    public void testA() {
        int a = 1;
        new Thread(() -> {
            int a = 3; // 与外部的局部变量重名,编译报错
            Integer num1 = 232; // 虽与域变量重名,允许,编译通过
            Integer num2 = 11; // 虽与静态变量重名,允许,编译通过
        }).start();
    }
}

友情提醒:虽然域变量和静态变量可以重名,从可读性的角度考虑,最好也不用重复,养成良好的编码习惯。

  • 规则 3:可使用 thissuper 关键字,等同于在普通方法中使用
public class AClass extends ParentClass {
    @Override
    public void printHello() {
        System.out.println("subClass: hello budy!");
    }

    @Override
    public void printName(String name) {
        System.out.println("subClass: name=" + name);
    }

    public void testA() {
        this.printHello();  // 输出:subClass: hello budy!
        super.printName("susu"); // 输出:ParentClass: name=susu

        new Thread(() -> {
            this.printHello();  // 输出:subClass: hello budy!
            super.printName("susu"); // 输出:ParentClass: name=susu
        }).start();

    }
}

class ParentClass {
    public void printHello() {
        System.out.println("ParentClass: hello budy!");
    }

    public void printName(String name) {
        System.out.println("ParentClass: name=" + name);
    }
}

对于 thissuper 关键字,大家记住一点就行啦:在 Lambda 表达式中使用,跟在普通方法中使用没有区别!

  • 规则 4:不能使用接口中的默认方法(default 方法)
public class AClass implements testInterface {
    public void testA() {
        new Thread(() -> {
            String name = super.getName(); // 编译报错:cannot resolve method 'getName()'
        }).start();
    }
}

interface testInterface {
    // 默认方法
    default public String getName() {
        return "susu";
    }
}

6.2 为何要 final?

不管是 Lambda 表达式,还是匿名内部类,编译器都要求了变量必须是 final 类型的,即使不显式声明,也要确保没有修改。那大家有没有想过,为何编译器要强制设定变量为 final 或 effectively final 呢?

  • 原因 1:引入的局部变量是副本,改变不了原本的值

看以下代码:

public static void main(String args[]) {
    int a = 3;
    String str = "susu";
    Susu123 susu123 = (x) -> System.out.println(x * 2 + str);
    susu123.print(a);
}

interface Susu123 {
    void print(int x);
}

在编译器看来,main 方法所在类的方法是如下几个:

public class Java8Tester {
    public Java8Tester(){
    }
    public static void main(java.lang.String[]){
        ...
    }
    private static void lambda$main$0(java.lang.String, int);
        ...
    }
}

可以看到,编译后的文件中,多了一个方法  lambda$main$0(java.lang.String, int),这个方法就对应了 Lambda 表达式。它有两个参数,第一个是 String 类型的参数,对应了引入的 局部变量 str,第二个参数是 int 类型,对应了传入的变量 a

若在 Lambda 表达式中修改变量 str 的值,依然不会影响到外部的值,这对很多使用者来说,会造成误解,甚至不理解。

既然在表达式内部改变不了,那就索性直接从编译器层面做限制,把有在表达式内部使用到的局部变量强制为 final 的,直接告诉使用者:这个局部变量在表达式内部不能改动,在外部也不要改啦!

  • 原因 2:局部变量存于栈中,多线程中使用有问题

大家都知道,局部变量是存于 JVM 的栈中的,也就是线程私有的,若 Lambda 表达式中可直接修改这边变量,会不会引起什么问题?

很多小伙伴想到了,如果这个 Lambda 表达式是在另一个线程中执行的,是拿不到局部变量的,因此表达式中拥有的只能是局部变量的副本。
如下的代码:

public static void main(String args[]) {
    int b = 1;
    new Thread(() -> System.out.println(b++));
}

假设在 Lambda 表达式中是可以修改局部变量的,那在上面的代码中,就出现矛盾了。变量 b 是一个局部变量,是当前线程私有的,而 Lambda 表达式是在另外一个线程中执行的,它又怎么能改变这个局部变量 b 的值呢?这是矛盾的。

  • 原因 3:线程安全问题

举一个经常被列举的一个例子:

public void test() {
    boolean flag = true;
    new Thread(() -> {
        while(flag) {
            ...
            flag = false;
        }
    });
    flag = false;
}

先假设 Lambda 表达式中的 flag 与外部的有关联。那么在多线程环境中,线程 A、线程 B 都在执行 Lambda 表达式,那么线程之间如何彼此知道 flag 的值呢?且外部的 flag 变量是在主线程的栈(stack)中,其他线程也无法得到其值,因此,这是自相矛盾的。

小结:

前面我们列举了多个局部变量必须为 final 或 effectively final 的原因,而 Lambda 表达式并没有对实例变量或静态变量做任何约束。

虽然没做约束,大家也应该明白,允许使用,并不代表就是线程安全的,看下面的例子:

// 实例变量
private int a = 1;

public static void main(String args[]) {
    Java8Tester java8Tester = new Java8Tester();
    java8Tester.test();
    System.out.println(java8Tester.a);

}

public void test() {
    for (int i = 0; i < 10; i++) {
        new Thread(() -> this.a++).start();
    }
}

以上的代码,并不是每次执行的结果都是 11,因此也存在线程安全问题。

7. Java 中的闭包

前面已经把 Lmabda 表达式讲的差不多了,是时候该讲一下闭包了。

闭包是函数式编程中的一个概念。在介绍 Java 中的闭包前,我们先看下 JavaScript 语言中的闭包。

function func1() {
  var s1 = 32;
    incre = function() {
        s1 + 1;
    };
    return function func2(y) {
        return s1 + y;
    };
}

tmp = func1();
console.log(tmp(1)); // 33

incre();
console.log(tmp(1)); // 34

上面的 JavaScript 示例代码中,函数 func2(y) 就是一个闭包,特征如下:

  • 第一点,它本身是一个函数,且是一个在其他函数内部定义的函数;
  • 第二点,它还携带了它作用域外的变量 s1,即外部变量

正常来说,语句 tmp = func1(); 在执行完之后,func1() 函数的声明周期就结束啦,并且变量 s1 还使用了 var 修饰符,即它是一个方法内的局部变量,是存在于方法栈中的,在该语句执行完后,是要随 func1() 函数一起被回收的。

但在执行第二条语句 console.log(tmp(1)); 时,它竟然没有报错,还仍然保有变量 s1 的值!

继续往下看。

在执行完第三条语句 incre(); 后,再次执行语句 console.log(tmp(1));,会发现输出值是 34。这说明在整个执行的过程中,函数 func2(y) 是持有了变量 s1 的引用,而不单纯是数值 32!

通过以上的代码示例,我们可以用依据通俗的话来总结闭包:

闭包是由函数和其外部的引用环境组成的一个实体,并且这个外部引用必须是在堆上的(在栈中就直接回收掉了,无法共享)。

在上面的 JavaScript 示例中,变量 s1 就是外部引用环境,而且是 capture by Reference

说完 JavaScript 中的闭包,我们再来看下 Java 中的闭包是什么样子的。Java 中的内部类就是一个很好的阐述闭包概念的例子。

public class OuterClass {
    private String name = "susu";

    private class InnerClass {
        private String firstName = "Shan";

        public String getFullName() {
            return new StringBuilder(firstName).append(" ").append(name).toString();
        }

        public OuterClass getOuterObj() {
            // 通过 外部类.this 得到对外部环境的引用
            return OuterClass.this;
        }
    }

    public static void main(String[] args) {
        OuterClass outerClass = new OuterClass();
        InnerClass innerClass = outerClass.new InnerClass();
        System.out.println(innerClass.getFullName());

        outerClass.name = "susu1";
        System.out.println(innerClass.getFullName());

        System.out.println(Objects.equals(outerClass, innerClass.getOuterObj()));
    }
}

#### 输出 ####
Shan susu
Shan susu1
true

上面的例子中,函数 getFullName() 就是一个闭包函数,其持有一个外部引用的变量 name,从输出结果可以看到,引用的外部变量变化,输出值也会跟随变化的,也是 capture by reference

内部类可以通过 外部类.this 来得到对外部环境的引用,上面示例的输出结果为 true 说明了这点。在内部类的 getFullName() 方法中,可直接引用外部变量 name,其实也是通过内部类持有的外部引用来调用的,比如,该方法也可以写成如下形式:

public String getFullName() {
    return new StringBuilder(firstName).append(" ").append(OutClass.this.name).toString();
}

OutClass.this 就是内部类持有的外部引用。

内部类可以有多种形式,比如匿名内部类,局部内部类,成员内部类(上面的示例中 InnerClass 类就是),静态内部类(可用于实现单例模式),这里不再一一列举。

对于 Lambda 表达式,在一定条件下可替换匿名内部类,但都是要求引入的外部变量必须是 final 的,前面也解释了为何变量必须是 final 的。

宽泛理解,Lambda 表达式也是一种闭包,也是在函数内部引入了外部环境的变量,但不同于 JavaScript 语言中的闭包,函数内一直持有外部变量,即使对应的外部函数已经销毁,外部变量依然可以存在并可以修改,Java 中 Lambda 表达式中对外部变量的持有,是一种值拷贝,Lambda 表达式内并不持有外部变量的引用,实际上是一种 capture by value,所以 Java 中的 Lambda 表达式所呈现的闭包是一种伪闭包。

8. Consumer、Supplier 等函数式接口

说实话,在第一次看到这类函数式接口的定时时,我是一脸懵逼的,这类接口有什么用?看不懂有什么含义,这类接口定义的莫名其妙。

就像 Consumer 接口的定义:

@FunctionalInterface
public interface Consumer<T> {
    /**
     * Performs this operation on the given argument.
     *
     * @param t the input argument
     */
    void accept(T t);

    default Consumer<T> andThen(Consumer<? super T> after) {
        Objects.requireNonNull(after);
        return (T t) -> { accept(t); after.accept(t); };
    }
}

单看 accept(T t)  抽象方法,需传入一个入参,没有返回值。这个方法做了啥?有什么语义上的功能吗?木有!

众所周知,Java 是一门面向对象的语言,一切皆对象。我们自定义的类(比如:HashMapArrayList)或方法(如:getName()execute()),都是有一定的语义(semantic)信息的,是暗含了它的使用范围和场景的,通俗点说,我们明显的可以知道它们可以干啥。

但回过头看 accept(T t)  这个抽象方法,你却不知道它是干啥的。其实,对于函数式接口中的抽象方法,它们是从另外一个维度去定义的,即结构化(structure)的定义。它们就是一种结构化意义的存在,本身就不能从语义角度去理解。

这里介绍几种常见的函数式接口的用法。

  • Consumer 接口:消费型函数式接口

从其抽象方法 void accept(T t) 来理解,就是一个参数传入了进去,整个方法的具体实现都与当前这个参数有关联。这与列表元素的循环获取很像,比如集合类的  Foreach() 方法:

default void forEach(Consumer<? super T> action) {
    Objects.requireNonNull(action);
    for (T t : this) {
        action.accept(t);
    }
}

再举一个例子。在日常开发中,可能会遇到连接,如数据库的连接,网络的连接等,假设有这么一个连接类:

public class Connection {

    public Connection() {
    }

    public void operate() {
        System.out.println("do something.");
    }

    public void close() {

    }

每次使用时,都需要创建连接、使用连接和关闭连接三个步骤,比如:

public void executeTask() {
    Connection conn = new Connection();
    try {
        conn.operate();
    } catch (Exception e) {
        e.printStackTrace();
    } finally {
        conn.close();
    }
}

当有多处代码都需要用到此类用法时,就需要在多处去创建连接、使用和关闭连接等操作。

这样有没有什么问题呢?万一某处代码忘记关闭其创建的连接对象,就可能会导致内存泄漏!

有没有比较好的方式呢?

可以将这部分常用代码做抽象,且不允许外部随意创建连接对象,只能自己创建自己的对象,如下:

public class Connection {

    private Connection() {

    }

    public void operate() {
        System.out.println("do something.");
    }

    public void close() {

    }

    public static void useConnection(Consumer<Connection> consumer) {
        Connection conn = new Connection();
        try {
            consumer.accept(conn);
        } catch (Exception e) {
        } finally {
            conn.close();
        }
    }
}

注意,上面的构造函数是私有的,从而避免了由外部创建 Connection 对象,同时在其内部提供了一个静态方法  useConnection() ,入参就是一个 Consumer 对象。当我们外部想使用时,使用如下调用语句即可:

Connection.useConnection(conn -> conn.operate());
  • Supplier 接口:供给型函数式接口

接口定义如下:

public interface Supplier<T> {
    /**
     * Gets a result.
     *
     * @return a result
     */
    T get();
}

抽象方法 T get() 没有入参,返回一个对象,和前面的 Consumer 接口的 void accept(T t) 抽象方法正好相反。

看下基本用法:

// 示例 1
Supplier<Integer> supplier1 = () -> Integer.valueOf(32);
System.out.println(supplier1.get());  // 32

// 示例 2
Supplier<Runnable> supplier2 = () -> () -> System.out.println("abc");
supplier2.get().run(); // abc

第 2 个示例,你有没有看糊涂?其等价代码如下:

Supplier<Runnable> supplier2 = () -> {
    Runnable runnable = () -> System.out.println("abc");
    return runnable;
};
supplier2.get().run();

像 Predicate、BiConsumer 等其他函数式接口,这里不再一一列举,感兴趣的小伙伴可自行查阅学习。

小结

关于 Lambda 表达式的知识点,上篇文章 深入探讨 Lambda 表达式(上)和本篇就已经全部介绍完毕。各位小伙伴,你都掌握了吗?

上一篇:【Python之旅】第一篇:基于文件处理的登陆接口


下一篇:Android开发学习知识点清单