Java 8函数式编程(一)-Lambda

函数式编程与面向对象编程的比较

函数式编程并不是Java新提出的概念,其与指令编程相比,强调函数的计算比指令的计算更重要;与过程化编程相比,其中函数的计算可以随时调用.
当然,大家应该都知道面向对象的特性(抽象、封装、继承、多态)。其实在Java8出现之前,我们关注的往往是某一类对象应该具有什么样的属性,当然这也是面向对象的核心–对数据进行抽象。但是java8出现以后,这一点开始出现变化,似乎在某种场景下,更加关注某一类共有的行为(这似乎与之前的接口有些类似),这也就是java8提出函数式编程的目的。
什么是函数式编程?在*中给出了详细的定义,函数式编程(Functional Programming)或称函数程序设计,又称泛函编程,是一种编程范型,它将电脑运算视为数学上的函数计算,并且避免使用程序状态以及易变对象。函数编程语言最重要的基础是λ演算(lambda calculus)。而且λ演算的函数可以接受函数当作输入(引数)和输出(传出值)。
而在面向对象编程中,面向对象程序设计(Object-Oriented Programming)是种具有对象概念的程序编程范型,同时也是一种程序开发的方法。它可能包含数据、属性、代码与方法。对象则指的是类的实例。它将对象作为程序的基本单元,将程序和数据封装其中,以提高软件的重用性、灵活性和扩展性,对象里的程序可以访问及经常修改对象相关连的数据。在面向对象程序编程里,计算机程序会被设计成彼此相关的对象。
对象与对象之间的关系是面向对象编程首要考虑的问题,而在函数式编程中,所有的数据都是不可变的,不同的函数之间通过数据流来交换信息,函数作为函数式编程中的一等公民,享有跟数据一样的地位,可以作为参数传递给下一个函数,同时也可以作为返回值。

Lambda表达式

// Java 8之前:
new Thread(new Runnable() {
    @Override
    public void run() {
        System.out.println("Before Java8, too much code for too little to do");
    }
}).start();

//Java 8方式:
new Thread( () -> System.out.println("In Java8, Lambda expression rocks !!") ).start();

// 一个参数省略括号
ActionListener oneArgument = event -> System.out.println("button clicked");

Runnable multiStatement = () -> {
    System.out.println("hello");
    System.out.println(" girl");        
};

// 不是两个数的相加, 而是创建了一个函数,用了计算两数相加的结果
BinaryOperator<Long> add = (Long x, Long y) -> x + y;
// 参数类型可以由编译推断出来
BinaryOperator<Long> addImplicit = (x, y) -> x + y;

接口的默认方法

Java SE 7时代为一个已有的类库增加功能是非常困难的。具体的说,接口在发布之后就已经被定型,除非我们能够一次性更新所有该接口的实现,否则向接口添加方法就会破坏现有的接口实现。默认方法(之前被称为虚拟扩展方法或守护方法)的目标即是解决这个问题,使得接口在发布之后仍能被逐步演化。
这里给出一个例子,我们需要在标准集合API中增加针对lambda的方法。例如removeAll方法应该被泛化为接收一个函数式接口Predicate,但这个新的方法应该被放在哪里呢?我们无法直接在Collection接口上新增方法——不然就会破坏现有的Collection实现。我们倒是可以在Collections工具类中增加对应的静态方法,但这样就会把这个方法置于“二等公民”的境地。
默认方法利用面向对象的方式向接口增加新的行为。它是一种新的方法:接口方法可以是抽象的或是默认的。默认方法拥有其默认实现,实现接口的类型通过继承得到该默认实现(如果类型没有覆盖该默认实现)。此外,默认方法不是抽象方法,所以我们可以放心的向函数式接口里增加默认方法,而不用担心函数式接口的单抽象方法限制。
下面的例子展示了如何向Iterator接口增加默认方法skip

interface Iterator<E> {
  boolean hasNext();
  E next();
  void remove();

  default void skip(int i) {
    for ( ; i > 0 && hasNext(); i -= 1) next();
  }
}

根据上面的Iterator定义,所有实现Iterator的类型都会自动继承skip方法。在使用者的眼里,skip不过是接口新增的一个虚拟方法。在没有覆盖skip方法的Iterator子类实例上调用skip会执行skip的默认实现:调用hasNextnext若干次。子类可以通过覆盖skip来提供更好的实现——比如直接移动游标(cursor),或是提供为操作提供原子性(Atomicity)等。
当接口继承其它接口时,我们既可以为它所继承而来的抽象方法提供一个默认实现,也可以为它继承而来的默认方法提供一个新的实现,还可以把它继承而来的默认方法重新抽象化。

接口的静态方法

除了默认方法,Java SE 8还在允许在接口中定义静态方法。这使得我们可以从接口直接调用和它相关的辅助方法(Helper method),而不是从其它的类中调用(之前这样的类往往以对应接口的复数命名,例如Collections)。比如,我们一般需要使用静态辅助方法生成实现Comparator的比较器,在Java SE 8中我们可以直接把该静态方法定义在Comparator接口中:

public static <T, U extends Comparable<? super U>>
    Comparator<T> comparing(Function<T, U> keyExtractor) {
  return (c1, c2) -> keyExtractor.apply(c1).compareTo(keyExtractor.apply(c2));
}

继承默认方法

和其它方法一样,默认方法也可以被继承,大多数情况下这种继承行为和我们所期待的一致。不过,当类型或者接口的超类拥有多个具有相同签名的方法时,我们就需要一套规则来解决这个冲突:
类的方法(class method)声明优先于接口默认方法。无论该方法是具体的还是抽象的。
被其它类型所覆盖的方法会被忽略。这条规则适用于超类型共享一个公共祖先的情况。
为了演示第二条规则,我们假设CollectionList接口均提供了removeAll的默认实现,然后Queue继承并覆盖了Collection中的默认方法。在下面的implement从句中,List中的方法声明会优先于Queue中的方法声明:

class LinkedList<E> implements List<E>, Queue<E> { ... }

当两个独立的默认方法相冲突或是默认方法和抽象方法相冲突时会产生编译错误。这时需要显式覆盖超类方法。一般来说我们会定义一个默认方法,然后在其中显式选择超类方法:

interface Robot implements Artist, Gun {
  default void draw() { Artist.super.draw(); }
}

super前面的类型必须是有定义或继承默认方法的类型。这种方法调用并不只限于消除命名冲突——我们也可以在其它场景中使用它。
最后,接口在inherits和extends从句中的声明顺序和它们被实现的顺序无关。

方法引用

诸如String::length的语法形式叫做方法引用(method references),这种语法用来替代某些特定形式Lambda表达式。如果Lambda表达式的全部内容就是调用一个已有的方法,那么可以用方法引用来替代Lambda表达式。方法引用可以细分为四类:

方法引用类别 举例
引用静态方法 ClassName::methodName IntBinaryOperator sum = Integer::sum;
引用某个对象的方法 instanceReference::methodName list::add
引用某个类的方法 ClassName::methodName String::length
引用构造方法 Class::new HashMap::new
// 引用静态方法
IntBinaryOperator sumOperator = Integer::sum;
int sum = sumOperator.applyAsInt(1, 2);
Assert.assertEquals(3, sum);

//引用某个对象的方法
List<String> strings = Arrays.asList("a", "b", "c");
Predicate<String> predicate = strings::contains;
boolean isExist = predicate.test("c");
Assert.assertTrue(isExist);

// 引用某个类的方法
Function<String, Integer> function = String::length;
int length = function.apply("abc");
Assert.assertEquals(3, length);

// 引用构造方法
UnaryOperator<HashMap> unaryOperator = HashMap::new;

Functional Interfaces (函数式接口)

Lambdas 只能在一个仅包含一个抽象方法的函数式接口上操作。函数式接口可以有任意的default 或者static 方法。 因此函数式接口有时候是说具有单个抽象方法的接口(SAM interfaces)。

interface Foo1 {
    void bar();
}

interface Foo2 {
    int bar(boolean baz);
}

interface Foo3 {
    String bar(Object baz, int mink);
}

interface Foo4 {
    default String bar() { // default不算
        return "baz";
    }
    void quux();
}

可以通过 @FunctionalInterface 注解来显式指定一个接口是函数式接口(以避免无意声明了一个符合函数式标准的接口),加上这个注解之后,编译器就会验证该接口是否满足函数式接口的要求。
Java 8 在包java.util.function 中提供了很多基本的模版函数式接口。
Supplier<T>: 数据提供器,可以提供T类型对象;无参的构造器,提供了get方法;
Function<T,R>: 数据转换器,接收一个T类型的对象,返回一个R类型的对象; 单参数单返回值的行为接口;提供了 apply, compose, andThen, identity方法;
Consumer<T>: 数据消费器,接收一个T类型的对象,无返回值,通常用于设置T对象的值;单参数无返回值的行为接口;提供了accept, andThen方法;
Predicate<T>: 条件测试器,接收一个T类型的对象,返回布尔值,通常用于传递条件函数;单参数布尔值的条件性接口。提供了test (条件测试) , and-or-negate(与-或-非) 方法。
其中, compose, andThen, and, or, negate 用来组合函数接口而得到更强大的函数接口。
其它的函数接口都是通过这四个扩展而来。
在参数个数上扩展: 比如接收双参数的,有 Bi 前缀, 比如 BiConsumer<T,U>, BiFunction<T,U,R> ;
在类型上扩展: 比如接收原子类型参数的,有[Int|Double|Long][Function|Consumer|Supplier|Predicate]
特殊常用的变形: 比如 BinaryOperator , 是同类型的双参数 BiFunction<T,T,T> ,二元操作符 ; UnaryOperatorFunction<T,T> 一元操作符。

Collections(集合类)

为引入Lambda表达式,Java 8新增了java.util.funcion包,里面包含常用的函数接口,这是Lambda表达式的基础,Java集合框架也新增部分接口,以便与Lambda表达式对接。
回顾一下Java集合框架的接口继承结构:
Java 8函数式编程(一)-Lambda
上图中绿色标注的接口类,表示在Java8中加入了新的接口方法,当然由于继承关系,他们相应的子类也都会继承这些新方法。下表详细列举了这些方法。

接口名 Java8新加入的方法
Collection removeIf()
spliterator()
stream()
parallelStream()
forEach()
List replaceAll()
sort()
Map getOrDefault()
forEach()
replaceAll()
putIfAbsent()
remove()
replace()
computeIfAbsent()
computeIfPresent()
compute()
merge()

这些新加入的方法大部分要用到java.util.function包下的接口,这意味着这些方法大部分都跟Lambda表达式相关。我们将逐一学习这些方法。

Collection中的新方法

removeIf()

该方法签名为boolean removeIf(Predicate<? super E> filter),作用是删除容器中所有满足filter指定条件的元素,其中Predicate是一个函数接口,里面只有一个待实现方法boolean test(T t),同样的这个方法的名字根本不重要,因为用的时候不需要书写这个名字。
示例:假设有一个字符串列表,需要删除其中所有长度大于3的字符串。
我们知道如果需要在迭代过程冲对容器进行删除操作必须使用迭代器,否则会抛出ConcurrentModificationException,所以上述任务传统的写法是:

// 使用迭代器删除列表元素
ArrayList<String> list = new ArrayList<>(Arrays.asList("I", "love", "you", "too"));
Iterator<String> it = list.iterator();
while(it.hasNext()){
    if(it.next().length()>3) // 删除长度大于3的元素
        it.remove();
}

现在使用removeIf()方法结合匿名内部类,我们可是这样实现:

// 使用removeIf()结合匿名名内部类实现
ArrayList<String> list = new ArrayList<>(Arrays.asList("I", "love", "you", "too"));
list.removeIf(new Predicate<String>(){ // 删除长度大于3的元素
    @Override
    public boolean test(String str){
        return str.length()>3;
    }
});

上述代码使用removeIf()方法,并使用匿名内部类实现Precicate接口。相信你已经想到用Lambda表达式该怎么写了:

// 使用removeIf()结合Lambda表达式实现
ArrayList<String> list = new ArrayList<>(Arrays.asList("I", "love", "you", "too"));
list.removeIf(str -> str.length()>3); // 删除长度大于3的元素

spliterator()

方法签名为Spliterator<E> spliterator(),该方法返回容器的可拆分迭代器。从名字来看该方法跟iterator()方法有点像,我们知道Iterator是用来迭代容器的,Spliterator也有类似作用,但二者有如下不同:
Spliterator既可以像Iterator那样逐个迭代,也可以批量迭代。批量迭代可以降低迭代的开销。
Spliterator是可拆分的,一个Spliterator可以通过调用Spliterator<T> trySplit()方法来尝试分成两个。一个是this,另一个是新返回的那个,这两个迭代器代表的元素没有重叠。
可通过(多次)调用Spliterator.trySplit()方法来分解负载,以便多线程处理。

Map中的新方法

相比CollectionMap中加入了更多的方法,我们以HashMap为例来逐一探秘。了解Java 7 HashMap实现原理,将有助于理解下文。

forEach()

该方法签名为void forEach(BiConsumer<? super K,? super V> action),作用是对Map中的每个映射执行action指定的操作,其中BiConsumer是一个函数接口,里面有一个待实现方法void accept(T t, U u)BinConsumer接口名字和accept()方法名字都不重要,请不要记忆他们。
需求:假设有一个数字到对应英文单词的Map,请输出Map中的所有映射关系.
Java 7以及之前经典的代码如下:

// Java7以及之前迭代Map
HashMap<Integer, String> map = new HashMap<>();
map.put(1, "one");
map.put(2, "two");
map.put(3, "three");
for(Map.Entry<Integer, String> entry : map.entrySet()){
    System.out.println(entry.getKey() + "=" + entry.getValue());
}

使用Map.forEach()方法,结合匿名内部类,代码如下:

// 使用forEach()结合匿名内部类迭代Map
HashMap<Integer, String> map = new HashMap<>();
map.put(1, "one");
map.put(2, "two");
map.put(3, "three");
map.forEach(new BiConsumer<Integer, String>(){
    @Override
    public void accept(Integer k, String v){
        System.out.println(k + "=" + v);
    }
});

上述代码调用forEach()方法,并使用匿名内部类实现BiConsumer接口。当然,实际场景中没人使用匿名内部类写法,因为有Lambda表达式:

// 使用forEach()结合Lambda表达式迭代Map
HashMap<Integer, String> map = new HashMap<>();
map.put(1, "one");
map.put(2, "two");
map.put(3, "three");
map.forEach((k, v) -> System.out.println(k + "=" + v));
}

getOrDefault()

该方法跟Lambda表达式没关系,但是很有用。方法签名为V getOrDefault(Object key, V defaultValue),作用是按照给定的key查询Map中对应的value,如果没有找到则返回defaultValue。使用该方法程序员可以省去查询指定键值是否存在的麻烦.
需求;假设有一个数字到对应英文单词的Map,输出4对应的英文单词,如果不存在则输出NoValue

// 查询Map中指定的值,不存在时使用默认值
HashMap<Integer, String> map = new HashMap<>();
map.put(1, "one");
map.put(2, "two");
map.put(3, "three");
// Java7以及之前做法
if(map.containsKey(4)){ // 1
    System.out.println(map.get(4));
}else{
    System.out.println("NoValue");
}
// Java8使用Map.getOrDefault()
System.out.println(map.getOrDefault(4, "NoValue")); // 2

putIfAbsent()

该方法跟Lambda表达式没关系,但是很有用。方法签名为V putIfAbsent(K key, V value),作用是只有在不存在key值的映射或映射值为null时,才将value指定的值放入到Map中,否则不对Map做更改.该方法将条件判断和赋值合二为一,使用起来更加方便.

remove()

我们都知道Map中有一个remove(Object key)方法,来根据指定key值删除Map中的映射关系;Java 8新增了remove(Object key, Object value)方法,只有在当前Mapkey正好映射到value时才删除该映射,否则什么也不做.

replace()

在Java 7及以前,要想替换Map中的映射关系可通过put(K key, V value)方法实现,该方法总是会用新值替换原来的值.为了更精确的控制替换行为,Java 8在Map中加入了两个replace()方法,分别如下:
replace(K key, V value),只有在当前Map中key的映射存在时才用value去替换原来的值,否则什么也不做.
replace(K key, V oldValue, V newValue),只有在当前Map中key的映射存在且等于oldValue时才用newValue去替换原来的值,否则什么也不做.

replaceAll()

该方法签名为replaceAll(BiFunction<? super K,? super V,? extends V> function),作用是对Map中的每个映射执行function指定的操作,并用function的执行结果替换原来的value,其中BiFunction是一个函数接口,里面有一个待实现方法R apply(T t, U u).不要被如此多的函数接口吓到,因为使用的时候根本不需要知道他们的名字.
需求:假设有一个数字到对应英文单词的Map,请将原来映射关系中的单词都转换成大写.
Java7以及之前经典的代码如下:

// Java7以及之前替换所有Map中所有映射关系
HashMap<Integer, String> map = new HashMap<>();
map.put(1, "one");
map.put(2, "two");
map.put(3, "three");
for(Map.Entry<Integer, String> entry : map.entrySet()){
    entry.setValue(entry.getValue().toUpperCase());
}

使用replaceAll()方法结合匿名内部类,实现如下:

// 使用replaceAll()结合匿名内部类实现
HashMap<Integer, String> map = new HashMap<>();
map.put(1, "one");
map.put(2, "two");
map.put(3, "three");
map.replaceAll(new BiFunction<Integer, String, String>(){
    @Override
    public String apply(Integer k, String v){
        return v.toUpperCase();
    }
});

上述代码调用replaceAll()方法,并使用匿名内部类实现BiFunction接口。更进一步的,使用Lambda表达式实现如下:

// 使用replaceAll()结合Lambda表达式实现
HashMap<Integer, String> map = new HashMap<>();
map.put(1, "one");
map.put(2, "two");
map.put(3, "three");
map.replaceAll((k, v) -> v.toUpperCase());

merge()

该方法签名为merge(K key, V value, BiFunction<? super V,? super V,? extends V> remappingFunction),作用是:
如果Map中key对应的映射不存在或者为null,则将value(不能是null)关联到key上;
否则执行remappingFunction,如果执行结果非null则用该结果跟key关联,否则在Map中删除key的映射.
参数中BiFunction函数接口前面已经介绍过,里面有一个待实现方法R apply(T t, U u)
merge()方法虽然语义有些复杂,但该方法的用方式很明确,一个比较常见的场景是将新的错误信息拼接到原来的信息上,比如:

map.merge(key, newMsg, (v1, v2) -> v1+v2);

compute()

该方法签名为compute(K key, BiFunction<? super K,? super V,? extends V> remappingFunction),作用是把remappingFunction的计算结果关联到key上,如果计算结果为null,则在Map中删除key的映射.
要实现上述merge()方法中错误信息拼接的例子,使用compute()代码如下:

map.compute(key, (k,v) -> v==null ? newMsg : v.concat(newMsg));

computeIfAbsent()

该方法签名为V computeIfAbsent(K key, Function<? super K,? extends V> mappingFunction),作用是:只有在当前Map中不存在key值的映射或映射值为null时,才调用mappingFunction,并在mappingFunction执行结果非null时,将结果跟key关联.
Function是一个函数接口,里面有一个待实现方法R apply(T t). computeIfAbsent()常用来对Map的某个key值建立初始化映射.比如我们要实现一个多值映射,Map的定义可能是Map<K,Set<V>>,要向Map中放入新值,可通过如下代码实现:

Map<Integer, Set<String>> map = new HashMap<>();
// Java7及以前的实现方式
if(map.containsKey(1)){
    map.get(1).add("one");
}else{
    Set<String> valueSet = new HashSet<String>();
    valueSet.add("one");
    map.put(1, valueSet);
}
// Java8的实现方式
map.computeIfAbsent(1, v -> new HashSet<String>()).add("yi");

使用computeIfAbsent()将条件判断和添加操作合二为一,使代码更加简洁.

computeIfPresent()

该方法签名为V computeIfPresent(K key, BiFunction<? super K,? super V,? extends V> remappingFunction),作用跟computeIfAbsent()相反,即,只有在当前Map中存在key值的映射且非null时,才调用remappingFunction,如果remappingFunction执行结果为null,则删除key的映射,否则使用该结果替换key原来的映射.
这个函数的功能跟如下代码是等效的:

// Java7及以前跟computeIfPresent()等效的代码
if (map.get(key) != null) {
    V oldValue = map.get(key);
    V newValue = remappingFunction.apply(key, oldValue);
    if (newValue != null)
        map.put(key, newValue);
    else
        map.remove(key);
    return newValue;
}
return null;

参考文献

  1. 函数式编程与面向对象编程的比较
  2. 深入理解Java 8 Lambda(语言篇——lambda,方法引用,目标类型和默认方法)
  3. Java8 Stream原理深度解析
  4. Lambda and Collections