Java 8 的新特性主要是三大方面, 一是引入了lambda表达式, 二是引入了Stream流, 三是接口中的默认方法. 在看完了基于Java 5的Thinking in Java之后, 就来补一下Java 8 的知识了.

函数式编程其实在之前自己有看过书学习过, 这里根据《Java 8 实战》这本书再来过一遍.

  1. 行为参数化
  2. lambda表达式
  3. 如何改造程序
  4. Java 8 提供的函数式接口
  5. 局部变量与闭包
  6. 方法引用
  7. 复合lambda表达式

行为参数化

有一个苹果类, 以及筛选苹果的方法, 如果更改筛选条件, 修改方法签名的话, 会导致大量代码被重用, 而且难以扩展.

import java.util.ArrayList;
import java.util.List;
import java.util.Random;

public class Apple {

    private String color;

    public Apple() {
        Random random = new Random();
        if (random.nextBoolean()) {
            color = "red";
        } else {
            color = "green";
        }
    }

    public String getColor() {
        return color;
    }

    @Override
    public String toString() {
        return "Apple{" +
                "color='" + color + '\'' +
                '}';
    }

    public static void main(String[] args) {

        List<Apple> apples = new ArrayList<>();

        for (int i = 0; i < 10; i++) {
            apples.add(new Apple());
        }
        System.out.println(apples);

        List<Apple> greenApples = filterGreenApples(apples);
        System.out.println(greenApples);

    }

    public static List<Apple> filterGreenApples(List<Apple> inventory) {
        List<Apple> result = new ArrayList<>();
        for (Apple apple : inventory) {
            if ("green".equals(apple.getColor())) {
                result.add(apple);
            }
        }
        return result;
    }

}

注意我们执行的筛选工作, 即对每个苹果是否满足我们的条件有一个真或者假的结论. 我们要做的就是, 传入这个条件和对应的判断方法. 而我们的过滤方法, 每次仅仅只使用返回true的苹果本身.

所以我们就无需扩展具体的要比较的参数, 而是扩展出一个条件参数. 由于Java 中毕竟不能直接传方法, 所以其实这个参数是一个类, 这个类里边的方法就是用来判断每个苹果是否符合类中的条件. 而我们的程序只要调用这个类就可以了.

假如我们的类叫做Condition, 其中判断的方法叫做boolean test(Apple apple), 程序可以修改如下:

public static List<Apple> filterApples(List<Apple> inventory, Condition condition) {
    List<Apple> result = new ArrayList<>();
    for (Apple apple : inventory) {
        if (condition.test(apple)) {
            result.add(apple);
        }
    }
    return result;
}

为了解耦, 很显然我们需要把Condition声明为一个接口, 然后在其中标注抽象方法boolean test(Apple apple):

public interface Condition {

    boolean test(Apple apple);
}

现在我们就完成了一个传入参数化条件的雏形, 也就是传入一个使用一定的条件测试的对象, 在实际使用中, 是这个样子:

public static void main(String[] args) {

    //随机准备10个颜色的苹果
    List<Apple> apples = new ArrayList<>();
    for (int i = 0; i < 10; i++) {
        apples.add(new Apple());
    }


    List<Apple> greenApples = filterApples(apples, new Condition() {
        @Override
        public boolean test(Apple apple) {
            return "green".equals(apple.getColor());
        }
    });
}

红色部分是一个匿名类, 在Java 8 之前, 只能这么写了(注意这里实际上是Strategy设计模式, 设计模式无处不在哦), 能不能进一步简化呢.

需要回过头来到我们的Condition接口中, 这个接口中仅仅有一个方法, 这个方法仅仅有一个签名, 没有重载, 接受一个参数返回一个值. 而且由于传递的是匿名对象, 那么能不能就用这个方法代替Condition对象, 然后让编译器自动创建一个匿名对象呢?

Java 8 就实现了这个功能, 虽然本质上传入一个包含所需要的方法的匿名类, 但是在编写代码的时候, 可以用lambda表达式(就是换了皮的一个方法)来代替匿名类, 这就是行为参数化.

还是上边的例子, 如果为了复用, 创建了一个过滤绿色的Condition实现类:

public class GreenCondition implements Condition {

    @Override
    public boolean test(Apple apple) {
        return "green".equals(apple.getColor());
    }
}

在使用的时候, 就不是传入匿名类, 而是传入这个对象:

List<Apple> greenApples = filterApples(apples, new GreenCondition());

此时能不能使用方法代替具体的类呢, 也是可以的.

无论上边哪种情况, 使用匿名类还是可复用的类, 其中起到关键作用的, 不是类, 而是其中的那个测试方法, 类是什么根本无所谓, 只要类型是我们的方法需要的类型即可.

所以可以先把我们的方法改成lambda表达式, 再传给方法即可.

这个方法首先接受一个Apple参数, 可以写成(Apple apple), 然后加上一个 -> 来表示从参数到结果. 结果里我们返回的是 “green”.equals(apple.getColor()), lambda如果返回单个结果, 可以直接不写return, 所以这个表达式就是:

"green".equals(apple.getColor())

然后就把这段东西传给方法, 再进一步, 可以发现参数的类型都无需传递, 因为已经知道了Condition接口中方法的签名:

List<Apple> greenApples = filterApples(apples, (apple) -> "green".equals(apple.getColor()));

当然还没完, 仔细想想就知道, 直接传一个表达式, 其实适合匿名类, 用一次就完事. 如果已经有了GreenCondition类如何呢?

对于已经存在的实体类, lambda表达式就需要修改一下, 这里依然注意观察, lambda表达式究竟是什么, 其实是一个方法签名加上方法体. 而对于已经存在的类来说, 方法签名和方法体都有了, 只需要类名和方法名就可以了. 于是就出现了方法引用:

不过这里要注意的是, GreenCondition::test不能使用在静态方法里, 需要调整一下代码放到一个非静态方法里.

List<Apple> greenApples = filterApples(apples, GreenCondition::test);

不过对于方法引用先放在后边, 还是先看lambda表达式.

lambda表达式

lambda表达式由参数, 箭头, 主体组成.

参数可以不指明类型, 只要编译器能够知道你传入的参数匹配的方法签名, 当然写上类型也是可以的.

箭头是固定的, 用在参数和返回值之间, 当然, 在嵌套的时候可能有些难懂, 符合左结合的规律.

主体是具体执行的操作, 也就是Condition接口里方法的具体实现, 可以是一行, 如果是一行, 就无需写return和末尾的分号. 也可以是多行, 多行的话就要用一个{}包起来, 然后需要使用return语句.

这里注意, 如果仅仅有一行, 但是想使用return语句, 则必须加上花括号, 而且这一行要以分号结束.

知道了lambda表达式, 有没有想过为什么可以传入一个lambda表达式, 这是因为我们定义的Condition接口, 只有一个抽象方法, 因此恰好符合Java 8 中函数式接口的定义, 如果再添加一个方法:

public interface Condition {

    boolean test(Apple apple);

    boolean test2(Apple apple);
}

就会发现无法使用lambda表达式了, 因为编译器无法知道到底要对应哪一个方法. 也就是说, 符合函数式接口的类(或者接口), 就可以通过lambda表达式, 将其当成一个函数对象(而不是带有一个方法的对象)来使用.

这里有人可能会问, 那我添加一个返回值不同的抽象方法呢, 是不是就可以区分了, 答案依然是不行, 因为Java中的函数签名不包含返回值

注意, 函数式接口只定义了一个抽象方法, 而不是只有一个方法, 还可以有若干默认方法, 但抽象方法只能有一个.

想编写一个函数式接口, 为了不出错, Java 8 提供了一个新的注解 @FunctionalInterface, 将其加在接口上, 就可以保证这个接口必须符合函数式接口, 否则无法编译通过. 这个注解来自于java.util.function, 这是Java 8 提供的函数式编程包, 晚点来专门看其中的奥秘.

时刻要记住, lambda表达式等于一个函数式接口的对象, 因此可以被返回, 可以被重用, 这很重要, 有了这个之后, 就可以来改造程序, 让其可以接受参数化对象.

如何改造程序

现在了解了参数化行为的思想之后, 很多程序就无需写死了, 而是可以灵活的进行改造.

在IO的时候, 经常需要读取文件. 如果要读取第一行, 可能就写死了读一行, 如果全部读出并放到一个字符串里, 可能又要写一个方法, 然后每次调用不同的方法.

在改造的时候, 采取如下步骤:

  1. 捕捉不同行为下的相同点, 无论读取一行, 还是读取两行, 最后返回的都是一个字符串, 因此 这是一个接受一个Reader对象, 返回字符串的行为
  2. 用函数式接口来规范行为
  3. 编写使用函数式接口对象的方法
  4. 在实际使用方法的时候, 传递lambda表达式

下边就按照上述步骤, 写一个可以参数化读取文件的方法. 捕捉行为的相同点已经分析过了, 那么先写接口:

@FunctionalInterface
public interface ReadFile {

    String read(BufferedReader bufferedReader) throws IOException;

}

这个接口符合函数式接口, 因此本身可以当成一个函数对象, 接受一个BufferedReader, 返回字符串.

然后像苹果那个例子一样, 编写一个静态方法, 其中只要使用了这个函数式接口即可:

public static String getContent(String filename, ReadFile p) {
    String result = "";
    try {
        BufferedReader bufferedReader = new BufferedReader(new FileReader(filename));
        result = p.read(bufferedReader);
    } catch (IOException e) {
        System.out.println("处理错误");
    }
    return result;
}

之后就可以传入参数化行为了:

public static void main(String[] args) {
    String result = getContent("program.txt", bufferedReader -> bufferedReader.readLine()+ "\n"+bufferedReader.readLine());

    System.out.println(result);

    String result2 = getContent("program.txt", bufferedReader -> bufferedReader.readLine());

    System.out.println(result2);
}

可见, 这里的ReadFile p 实际上和lambda 表达式是等价的, 所以lambda本质就是一个函数式接口的实现类的对象.

Java 8 提供的函数式接口

在之前看函数式编程的时候, 简略的看过这一段, 但是没有总结充分. 现在可以列出常见的了:

  1. Predicate<T>, 这个接口定义了test方法, 接受泛型T, 返回boolean类型. 这个类似于一个测试, 凡是需要过滤和判断的地方, 都可以采用此接口
  2. Consumer<T>, 这个接口定义了一个accept的方法, 接受泛型T, 返回void, 其实顾名思义, 就相当于消费掉了这个数据, 没有返回值.
  3. Function<T, R>, 这个顾名思义, 就是一个抽象的函数, 其中有一个apply方法, 接受T类型, 返回R类型, 相当于从T到R的映射. 当然这其中还有一个compose和一个andthen, 属于高级的用法
  4. 类型特化接口, 这个是针对java.util.function中所有的泛型接口, 在操作基本类型的时候, 因为自动装箱拆箱机制造成的性能低下, 直接会接受泛型返回基本类型或者在基本类型之间转换, 具体有很多, 只要记住涉及到基本类型, 就要想起类型特化接口
  5. Supplier<T>, 其中定义了get()方法, 无参数, 返回T类型, 由于这个函数的意义就是无中生有, 创造出T类型, 所以顾名思义称作Supplier接口
  6. UnaryOperator<T>, 这其中定义了一个:
        static <T> UnaryOperator<T> identity() {
            return (t) -> {
                return t;
            };
        }
    

    本质就是接受一个T返回一个T, 是一个对一个对象进行某些操作的抽象. 这个接口实际上是继承了Function<T,T>接口.

  7. BiFunction<T, U, R>, 二元得到一元, 其中也是apply方法, 接受T和U类型参数, 返回R参数.
  8. BinaryOperator<T>, 这个接口实际上继承BiFunction<T, T, T>, 即接受两个T类型, 返回一个T类型, 可以用于聚合. 还提供了minBy和maxBy两个方法.
  9. BiPredicate<L, R>, 这个接口的方法是 test(T var1, U var2);接受L和R类型, 返回boolean, 用来同时测试两个对象. 此外还有几个默认方法.
  10. BiConsumer<T, U>, 和Consumer接口一样, 方法是accept(T var1, U var2);接受T和U类型, 返回void, 所以也是一个Consumer

局部变量与闭包

前边编写的lambda表达式都仅仅使用了其自己方法体内部的局部变量, 如果使用了外部的局部变量会如何呢.

实际上, 在lambda执行的时候, 会捕获当时的局部变量, 实际上那个局部变量事实上成为final.

原因是程序基础, 一个类相当于一个C语言的模板, 局部变量是在栈中, 运行完毕之后就无法保证其中的内容, 所以不能访问栈空间, 实际上保存的是这个栈中的变量的副本, 所以也不能重新赋值.

使用实例变量则是可以的, 因为实例变量保存在堆中, 这和线程也有关系.

方法引用

关于方法引用要明确的就是, 方法引用不是一种新写法, 只是lambda表达式的快捷写法. 用在你已经知道了具体的类和具体的方法.

在之前我们程序中使用的都是接口, 传入的lambda其实表示匿名对象, 但如果确定的知道要使用某一个类的某一个方法, 就可以考虑直接传入符合接口的方法引用形式, 而无需采用lambda 表达式.

来看一个简单的例子:

public interface ConsumeApple extends Consumer<Apple> {
}

这个接口继承Consumer<Apple>, 则其中有了一个方法 accept接受Apple对象, 返回void, 其实就可以消费Apple对象了.

再编写一个程序, 接受Apple对象和这个消费Apple对象的方法:

public class UseApple {

    public static void processApple(Apple apple, ConsumeApple consumeApple) {
        consumeApple.accept(apple);
    }

}

UseApple类中的processApple方法, 用参数化的行为来处理传入的apple对象.

在使用的时候, 我们可以手写一个lambda表达式:

public static void main(String[] args) {

    Apple apple = new Apple();

    processApple(apple, apple1 -> {
        System.out.println("apple is" + apple);
    });
}

注意我们这里的lambda表达式, 是接受Apple返回void的函数. 从之前的学习知道, 传入一个符合这个方法签名的lambda即可.

然后发现System.out.println()方法, 恰好也符合接受Apple返回void的要求, 是不是可以传呢, 是可以传的, println是一个静态方法, 因此可以使用方法引用, 即类名::方法名:

public static void main(String[] args) {

    Apple apple = new Apple();

    processApple(apple, System.out::println);
}

注意这里是静态方法引用, 只是方法引用的一种, 这是为了说明一个要点: 方法引用不过是lambda的语法糖, 用在精确的知道什么类和具体方法的时候, 而不像lambda实际上是匿名对象.

方法引用一共有三种, 需要仔细注意其中的区分:

  1. 指向静态方法的方法引用, 使用类名::方法名. 注意这个类名和方法名都是不会改变的, 所以就直接用类名::方法名
  2. 第二种比较特殊, 指向不特定对象的实例方法, 指的是这个方法能够接受一个任意的类的实例, 然后调用对应的方法, 这个对应lambda表达式的关系如下:
        (arg0, rest) -> arg0.instanceMethod(rest)
    

    简单的说, lambda表达式接受两个参数, 一个是对象, 一个是目标参数, 在程序中会调用对象的实例方法, 传入目标参数, 这个时候就需要用arg0所属的类名::实例方法.
    第二种是最让人迷惑的, 我之前自学的时候就没有区分这三类, 导致看不懂, 现在就明白多了.

  3. 第三类, 是指向现有特定对象的实例方法, 使用实例变量名::方法名.

此外还有指向构造函数的类名::new, 数组构造函数和父类调用.

要注意还是第二种, 看到类似于两个对象进行比较, 一个参数使用以第二个参数为参数的实例方法,都要想到第二种, 只要lambda符合, 就可以用.

还有一点时刻要记住, lambda是一个对象, 可以用接口类型变量为其赋值一个lambda, 一定要时刻记住.

复合Lambda表达式

函数式接口有一些默认方法, 其实是起到复合作用, 即对结果再应用函数. 比如常见的谓词, 就是一个判断, 则谓词很显然可以用逻辑运算符来连接, 其中的默认方法起到的就是这个作用.

常见的有:

  1. 比较器复合, inventory.sort(comparing(Apple::getWeight).reversed());
  2. 比较器链, inventory.sort(comparing(Apple::getWeight).reversed().thenComparing(Apple::getCountry));
  3. 谓词复合, negate、and和or, 用来重用Predicte<T>:Predicate<Apple> notRedApple = redApple.negate();Predicate<Apple> redAndHeavyApple = redApple.and(a -> a.getWeight() > 150);Predicate<Apple> redAndHeavyAppleOrGreen = redApple.and(a -> a.getWeight() > 150).or(a -> “green”.equals(a.getColor()));
  4. 函数复合, Function接口提供了andThen和compose, 返回Function的一个实例, 例如可以很方便的实现 g(f(x)):<br>
    Function<Integer, Integer> f = x -> x + 1;
    Function<Integer, Integer> g = x -> x * 2;
    Function<Integer, Integer> h = f.andThen(g);
    int result = h.apply(1);
    而compose表示自己把作为compose参数的函数当成自己的参数, 即自己的组成部件是g, 例如:
    Function<Integer, Integer> f = x -> x + 1;
    Function<Integer, Integer> g = x -> x * 2;
    Function<Integer, Integer> h = f.compose(g);
    int result = h.apply(1);
    这表示的是f(g(x))

有了函数式编程之后, 在今后的编程中, 就要注意使用策略模式, 然后传递lambda表达式了.