V2EX = way to explore
V2EX 是一个关于分享和探索的地方
现在注册
已注册用户请  登录
NGLSL
V2EX  ›  Java

《Java8 实战》-第三章读书笔记(Lambda 表达式-02)

  •  
  •   NGLSL ·
    nglsl · 2018-08-18 20:17:53 +08:00 · 1515 次点击
    这是一个创建于 2295 天前的主题,其中的信息可能已经有所发展或是发生改变。

    由于第三章的内容比较多,而且为了让大家更好的了解 Lambda 表达式的使用,也写了一些相关的实例,可以在 Github 或者码云上拉取读书笔记的代码进行参考。

    类型检查、类型推断以及限制

    当我们第一次提到 Lambda 表达式时,说它可以为函数式接口生成一个实例。然而,Lambda 表达式本身并不包含它在实现哪个函数式接口的信息。为了全面了解 Lambda 表达式,你应该知道 Lambda 的实际类型是什么。

    类型检查

    Lambda 的类型是从使用 Lambda 上下文推断出来的。上下文(比如,接受它传递的方法的参数,或者接受它的值得局部变量)中 Lambda 表达式需要类型称为目标类型。

    同样的 Lambda,不同的函数式接口

    有了目标类型的概念,同一个 Lambda 表达式就可以与不同的函数接口关联起来,只要它们的抽象方法能够兼容。比如,前面提到的 Callable,这个接口代表着什么也不接受且返回一个泛型 T 的函数。

    同一个 Lambda 可用于多个不同的函数式接口:

    Comparator<Apple> c1 = (Apple a1, Apple a2) -> a1.getWeight().compareTo(a2.getWeight());
    ToIntBiFunction<Apple, Apple> c2 = (Apple a1, Apple a2) -> a1.getWeight().compareTo(a2.getWeight());
    BiFunction<Apple, Apple, Integer> c3 = (Apple a1, Apple a2) -> a1.getWeight().compareTo(a2.getWeight());;
    

    是的,ToIntFunction 和 BiFunction 都是属于函数式接口。还有很多类似的函数式接口,有兴趣的可以去看相关的源码。

    到目前为止,你应该能够很好的理解在什么时候以及在哪里使用 Lambda 表达式了。它们可以从赋值的上下文、方法调用(参数和返回值),以及类型转换的上下文中获得目标类型。为了更好的了解 Lambda 表达的时候方式,我们来看看下面的例子,为什么不能编译:

    Object o = () -> {System.out.println("Tricky example");};
    

    答案:很简单,我们都知道 Object 这个类并不是一个函数式接口,所以它不支持这样写。为了解决这个问题,我们可以把 Object 改为 Runnable,Runnable 是一个函数式接口,因为它只有一个抽象方法,在上一节的读书笔记中我们有提到过它。

    Runnable r = () -> {System.out.println("Tricky example");};
    

    你已经见过如何利用目标类型来检查一个 Lambda 是否可以用于某个特定的上下文。其实,它也可以用来做一些略有不同的事情:tuiduanLambda 参数的类型。

    类型推断

    我们还可以进一步的简化代码。Java 编译器会从上下文(目标类型)推断出用什么函数式接口来匹配 Lambda 表达式,这意味着它也可以推断出适合 Lambda 的签名,因为函数描述符可以通过目标类型来得到。这样做的好处在于,编译器可以了解 Lambda 表达式的参数类型,这样就可以在 Lambda 与法中省去标注参数类型。换句话说,Java 编译器会向下面这样推断 Lambda 的参数类型:

    // 参数 a 没有显示的指定类型
    List<Apple> greenApples = filter(apples, a -> "green".equals(a.getColor()));
    

    Lambda 表达式有多个参数,代码可独行的好处就更为明显。例如,你可以在这用来创建一个 Comparator 对象:

    // 没有类型推断,显示的指定了类型
    Comparator<Apple> cApple1 = (Apple a1, Apple a2) -> a1.getWeight().compareTo(a2.getWeight());
    
    // 有类型推断,没有现实的指定类型
    Comparator<Apple> cApple2 = (a1, a2) -> a1.getWeight().compareTo(a2.getWeight());
    

    有时候,指定类型的情况下代码更易读,有时候去掉它们也更易读。并没有说哪个就一定比哪个好,需要根据自身情况来选择。

    使用局部变量

    我们迄今为止所介绍的所有 Lambda 表达式都只用到了其主体里的参数。但 Lambda 表达式也允许用外部变量,就像匿名类一样。他们被称作捕获 Lambda。例如:下面的 Lambda 捕获了 portNumber 变量:

    int portNumber = 6666;
    Runnable r3 = () -> System.out.println(portNumber);
    

    尽管如此,还有一点点小麻烦:关于能对这些变量做什么有一些限制。Lambda 可以没有限制地捕获(也就是在主体中引用)实例变量和静态变量。但局部变量必须显示的声明 final,或实际上就算 final。换句话说,Lambda 表达式只能捕获指派给它们的局部变量一次。(注:捕获实例变量可以被看作捕获最终局部变量 this )。例如,下面的代码无法编译。

    int portNumber = 6666;
    Runnable r3 = () -> System.out.println(portNumber);
    portNumber = 7777;
    

    portNumber 是一个 final 变量,尽管我们没有显示的去指定它。但是,在代码编译的时候,编译器会自动给这个变量加了一个 final,起码我看反编译后的代码是有一个 final 的。

    对于局部变量的限制

    你可能会有一个疑问,为什么局部变量会有这些限制。第一个,实例变量和局部变量背后的实现有一个关键不同。实例变量都存储在堆中,而局部变量则保存在栈上。如果 Lambda 可以直接访问局部变量,而且 Lambda 是在一个线程中使用,则使用 Lambda 的线程,可能会在分配该变量的线程将这个变量回收之后,去访问该变量。因此,Java 在访问自由局部变量是,实际上是在访问它的副本,而不是访问原始变量。如果局部变量仅仅复制一次那就没什么区别了,因此就有了这个限制。

    现在,我们来了解你会在 Java8 代码中看到的另一个功能:方法引用。可以把它们视为某些 Lambda 的快捷方式。

    方法引用

    方法引用让你可以重复使用现有的方法,并像 Lambda 一样传递它们。在一些情况下,比起用 Lambda 表达式还要易读,感觉也更自然。下面就是我们借助 Java8 API,用法引用写的一个排序例子:

    // 之前
    apples.sort((Apple a1, Apple a2) -> a1.getWeight().compareTo(a2.getWeight()));
    // 之后,方法引用
    apples.sort(Comparator.comparing(Apple::getWeight));
    

    酷,使用::的代码看起来更加简洁。在此之前,我们也有使用到过,它的确看起来很简洁。

    管中窥豹

    方法引用可以被看作仅仅调用特定方法的 Lambda 的一种快捷写法。它的基本思想是,如果一个 Lambda 代表的只是:“直接调用这个方法”,那最好还是用名称来调用它,而不是去描述如何调用它。事实上,方法引用就是让你根据已有的方法实现来创建 Lambda 表达式。但是,显示地指明方法的名称,你的代码可读性会更好。它是如何工作的?当你需要使用方法引用是,目标引用放在分隔符::前,方法的名称放在后面。 例如,Apple::getWeight 就是引用了 Apple 类中定义的 getWeight 方法。请记住,不需要括号,因为你没有实际调用这个方法。方法引用就是用 Lambda 表达式(Apple a) -> a.getWeight()的快捷写法。

    我们接着来看看关于 Lambda 与方法引用等效的一些例子:

    Lambda:(Apple a) -> a.getWeight() 
    方法引用:Apple::getWeight
    
    Lambda:() -> Thread.currentThread().dumpStack() 
    方法引用:Thread.currentThread()::dumpStack
    
    Lambda:(str, i) -> str.substring(i)
    方法引用:String::substring
    
    Lambda:(String s) -> System.out.println(s)
    方法引用:System.out::println
    

    你可以把方法引用看作是 Java8 中个一个语法糖,因为它简化了一部分代码。

    构造函数引用

    对于一个现有的构造函数,你可以利用它的名称和关键字 new 来创建它的一个引用:ClassName::new。如果,一个构造函数没有参数,那么可以使用 Supplier 来创建一个对象。你可以这样做:

    Supplier<Apple> c1 = Apple::new;
    Apple apple = c1.get();
    

    这样做等价于

    Supplier<Apple> c1 = () -> new Apple();
    Apple apple = c1.get();
    

    如果,你的构造函数的签名是 Apple(Integer weight),那么可以使用 Function 接口的签名,可以这样写:

    Function<Integer, Apple> c2 = Apple::new;
    Apple a2 = c2.apply(120);
    

    这样做等价于

    Function<Integer, Apple> c2 = (weight) -> new Apple(weight);
    Apple a2 = c2.apply(120);
    

    如果有两个参数 Apple(weight, color),那么我们可以使用 BiFunction:

    BiFunction<Integer, String, Apple> c3 = Apple::new;
    Apple a3 = c3.apply(120, "red");
    

    这样做等价于

    BiFunction<Integer, String, Apple> c3 =(weight, color) -> new Apple(weight, color);
    Apple a3 = c3.apply(120, "red");
    

    到目前为止,我们了解到了很多新内容:Lambda、函数式接口和方法引用,接下来我们将把这一切付诸实践。

    Lambda 和方法引用实战

    为了更好的熟悉 Lambda 和方法引用的使用,我们继续研究开始的那个问题,用不同的排序策略给一个 Apple 列表排序,并需要展示如何把一个圆使出报的解决方案变得更为简明。这会用到我们目前了解到的所有概念和功能:行为参数化、匿名类、Lambda 表达式和方法引用。我们想要实现的最终解决方案是这样的:

    apples.sort(comparing(Apple::getWeight));
    

    第 1 步:代码传递

    很幸运,Java8 的 Api 已经提供了一个 List 可用的 sort 方法,我们可以不用自己再去实现它。那么最困难部分已经搞定了!但是,如果把排序策略传递给 sort 方法呢?你看,sort 方法签名是这样的:

    void sort(Comparator<? super E> c)
    

    它需要一个 Comparator 对象来比较两个 Apple !这就是在 Java 中传递策略的方式:它们必须包裹在一个对象利。我们说 sort 的行为被参数化了了:传递给他的排序策略不同,其行为也会不同。

    可能,你的第一个解决方案是这样的:

    public class AppleComparator implements Comparator<Apple> {
        @Override
        public int compare(Apple o1, Apple o2) {
            return o1.getWeight().compareTo(o2.getWeight());
        }
    }
    
    apples.sort(new AppleComparator());
    

    它确实能实现排序,但是还需要去实现一个接口,并且排序的规则也不复杂,或许它还可以简化一下。

    第 2 步:使用匿名类

    或许你已经想到了一个简化代码的办法,就是使用匿名类而且每次使用只需要实例化一次就可以了:

    apples.sort(new Comparator<Apple>() {
        @Override
        public int compare(Apple o1, Apple o2) {
            return o1.getWeight().compareTo(o2.getWeight());
        }
    });
    

    看上去确实简化一些,但感觉还是有些啰嗦,我们接着继续简化:

    第 3 步:使用 Lambda 表达式

    我们可以使用 Lambda 表达式来替代匿名类,这样可以提高代码的简洁性和开发效率:

    apples.sort((o1, o2) -> o1.getWeight().compareTo(o2.getWeight()));
    

    太棒了!这样的代码看起来很简洁,原来四五行的代码只需要一行就可以搞定了!但是,我们还可以使这行代码更加的简洁!

    第 4 步:使用方法引用

    使用 Lambda 表达式的代码确实简洁了不少,那你还记得我们前面说的方法引用吗?它是 Lambda 表达式的一种快捷写法,相当于是一种语法糖,那么我们来试试糖的滋味如何:

    apples.sort(Comparator.comparing(Apple::getWeight));
    

    恭喜你,这就是你的最终解决方案!这样的代码比真的很简洁,这比 Java8 之前的代码好了很多。这样的代码比较简短,它的意思也很明显,并且代码读起来和问题描述的差不多:“对库存进行排序,比较苹果的重量”。

    复合(组合) Lambda 表达式的有用方法

    Java8 的好几个函数式接口都有为方便而设计的的方法。具体而言,许多函数式接口,比如用于传递 Lambda 表达式的 Comparator、Function 和 Predicate 都提供了允许你进行复合的方法。这是什么意思呢?在实践中,这意味着你可以把多个简单的 Lambda 复合成复杂的表达式。比如,你可以让两个谓词之间做一个 or 操作,组合成一个更大的谓词。而且,你还可以让一个函数的结果成为另一个函数的输入。你可能会想,函数式接口中怎么可能有更多的方法?(毕竟,这违背了函数式接口的定义,只能有一个抽象方法)还记得我们上一节笔记中提到默认方法吗?它们不是抽象方法。关于默认方法,我们以后在进行详细的了解吧。

    比较复合器

    还记刚刚我们对苹果的排序吗?它只是一个从小到大的一个排序,现在我们需要让它进行逆序。看看刚刚方法引用的代码,你会发现它貌似无法进行逆序啊!不过不用担心,我们可以让它进行逆序,而且很简单。

    1.逆序

    想要实现逆序其实很简单,需要使用一个 reversed()方法就可以完成我们想要的逆序排序:

    apples.sort(Comparator.comparing(Apple::getWeight).reversed());
    

    按重量递减排序,就这样完成了。这个方法很有用,而且用起来很简单。

    2.比较器链

    上面的代码很简单,但是你仔细想想,如果存在两个一样重的苹果谁前谁后呢?你可能需要再提供一个 Comparator 来进一步定义这个比较。比如,再按重量比较了两个苹果之后,你可能还想要按原产国进行排序。thenComparing 方法就是做这个用的。它接受一个函数作为参数(就像 comparing 方法一样),如果两个对象用第一个 Comparator 比较之后还是一样,就提供第二个 Comparator。我们又可以优雅的解决这个问题了:

    apples.sort(Comparator.comparing(Apple::getWeight).reversed()
                    .thenComparing(Apple::getCountry));
    

    复合谓词

    谓词接口包括了三个方法: negate、and 和 or,让你可以重用已有的 Predicate 来创建更复杂的谓词。比如,negate 方法返回一个 Predicate 的非,比如苹果不是红的:

    private static <T> List<T> filter(List<T> list, Predicate<T> predicate) {
        List<T> result = new ArrayList<>();
        for (T t : list) {
            if (predicate.test(t)) {
                result.add(t);
            }
        }
        return result;
    }
    
    List<Apple> apples = Arrays.asList(new Apple(150, "red"), new Apple(110, "green"), new Apple(100, "green"));
    // 只要红苹果
    Predicate<Apple> apple = a -> "red".equals(a.getColor());
    // 只要红苹果的非
    Predicate<Apple> notRedApple = apple.negate();
    // 筛选
    List<Apple> appleList = filter(apples, notRedApple);
    // 遍历打印
    appleList.forEach(System.out::println);
    

    你可能还想要把 Lambda 用 and 方法组合起来,比如一个苹果即是红色的又比较重:

    Predicate<Apple> redAndHeavyApple = apple.and(a -> a.getWeight() >= 150);
    

    你还可以进一步组合谓词,表达要么是重的红苹果,要么是绿苹果:

     Predicate<Apple> redAndHeavyAppleOrGreen =
                    apple.and(a -> a.getWeight() >= 150)
                            .or(a -> "green".equals(a.getColor()));
    

    这一点为什么很好呢?从简单的 Lambda 表达式出发,你可以构建更复杂的表达式,但读起来仍然和问题陈述的差不多!请注意,and 和 or 方法是按照表达式链中的位置,从左向右确定优先级的。因此,a.or(b).and(c)可以看作(a || b) && c。

    函数复合

    最后,你还可以把 Function 接口所代表的 Lambda 表达式复合起来。Function 接口为此匹配了 andThen 和 compose 两个默认方法,它们都会返回 Function 的一个实例。

    andThen 方法会返回一个函数,它先对输入应用一个给定函数,再对输出应用另一个函数。假设,有一个函数 f 给数字加 1(x -> x + 1),另外一个函数 g 给数字乘 2,你可以将它们组合成一个函数 h:

    Function<Integer, Integer> f = x -> x + 1;
    Function<Integer, Integer> g = x -> x * 2;
    Function<Integer, Integer> h = f.andThen(g);
    // result = 4
    int result = h.apply(1);
    

    你也可以类似地使用 compose 方法,先把给定的函数左右 compose 的参数里面给的那个函数,然后再把函数本身用于结果。比如在上一个例子用 compose 的化,它将意味着 f(g(x)),而 andThen 则意味着 g(f(x)):

    Function<Integer, Integer> f1 = x -> x + 1;
    Function<Integer, Integer> g1 = x -> x * 2;
    Function<Integer, Integer> h1 = f1.compose(g1);
    // result1 = 3
    int result1 = h1.apply(1);
    

    它们的关系如下图所示: image

    compose 和 andThen 的不同之处就是函数执行的顺序不同。compose 函数限制先参数,然后执行调用者,而 andThen 限制先调用者,然后再执行参数。

    总结

    在《 Java8 实战》第三章中,我们了解到了很多概念关键的念。

    1. Lambda 表达式可以理解为一种匿名函数:它没有名称,但有参数列表、函数主体、返回类型,可能还有一个可抛出的异常列表。
    2. Lambda 表达式让我们可以简洁的传递代码。
    3. 函数式接口就是仅仅只有一个抽象方法的接口。
    4. 只有在接受函数式接口的地方才可以使用 Lambda 表达式。
    5. Lambda 表达式允许你直接内联,为函数式接口的抽象方法提供实现,并且将整个表达式作为函数式接口的一个实例。
    6. Java8 自带一些常用的函数式接口,在 java.util.function 包里,包括了 Predicate<t>、Function<T, R>、Supplier<t>、Consumer<t>和 BinaryOperatory<t>。</t></t></t></t>
    7. 为了避免装箱操作,等于 Predicate<t>和 Function<T, R>等通用的函数式接口的原始类型特化:IntPredicate、IntToLongFunction 等。</t>
    8. Lambda 表达式所需要代表的类型称为目标类型。
    9. 方法引用可以让我们重复使用现有的方法实现并且直接传递它们。
    10. Comparator、Predicate 和 Function 等函数式接口都有几个可以用来结合 Lambda 表达式的默认方法。

    第三章的内容确实很多,而且这一章的内容也很重要,如果你有兴趣那么请慢慢的看,最好自己能动手写写代码否则过不了多久就会忘记了。

    第三章笔记中的代码:

    Github: chap3

    Gitee: chap3

    如果,你对 Java8 中的新特性很感兴趣,你可以关注我的公众号或者当前的技术社区的账号,利用空闲的时间看看我的文章,非常感谢!

    2 条回复    2018-08-21 18:14:48 +08:00
    jorneyr
        1
    jorneyr  
       2018-08-19 14:58:30 +08:00
    总结的不错
    NGLSL
        2
    NGLSL  
    OP
       2018-08-21 18:14:48 +08:00
    @jorneyr 谢谢
    关于   ·   帮助文档   ·   博客   ·   API   ·   FAQ   ·   实用小工具   ·   1005 人在线   最高记录 6679   ·     Select Language
    创意工作者们的社区
    World is powered by solitude
    VERSION: 3.9.8.5 · 23ms · UTC 19:49 · PVG 03:49 · LAX 11:49 · JFK 14:49
    Developed with CodeLauncher
    ♥ Do have faith in what you're doing.