Java 8 函数式编程入门之Lambda

前言
Java 8最大的变化非lambda莫属,Java终于可以探索函数式编程的道路。本文专注于Java 8中的lambda及相关的知识点进行介绍,而对于stream流式处理则计划在下一篇文章中进行介绍。

Java 8 函数式编程入门之Lambda

引子

业界大牛Steve Yegge曾经讲述过Java魔鬼国王在全国范围内驱逐动词的故事:

在Java王国中,国王Java靠铁腕统治着他的国家。在这里,名词是最重要的居民,它们扮演了社会主体。相比之下,动词的处境则糟糕的多,它们负责了王国里的所有工作,但却得不到任何尊重;它们没有办法单独出现在社会上,一旦出现,变会立即被名词逮捕。然而"逮捕"本身也是动词,必须去创造一个"逮捕者"协助执行逮捕的动作,而创造和协助又同样是动词…而在世界另一边,有一片贫瘠的土地。这里动词和名词一样是"一等公民"。事实上,名词几乎无所事事,单独出现也没有什么意义,而动词也没有奇怪的法律要求必须被名词包裹…

那么Java王国中的居民真的快乐么?

—————————— 改述自*《名词王国里的死刑》*

要么改变,要么衰亡。通过Java 7 DynamicInvoke的预热,Java 8最大的变化就是引入Lambda表达式,一定程度上支持了函数式编程的范式。相比Lisp自由的语法、Haskell艰深的Monad概念,Java 8 Lambda只提供了非常基础的函数式语法,虽然简单,但也足够友好实用。

Java 8提供了"行为参数化"的模式,支持Lambda匿名函数、闭包、高阶函数等,支持类型推断,同时内置了一批实用的函数接口,并制定了自定义函数接口的设计规范。

Lambda表达式

从匿名类到Lambda

在Java 8出现之前,参数传递上只有基础类型、对象引用、或者接口注入的形式。但我们如果只希望在类的内部使用某个接口时,我们没有必要为每个类都设计一个实现接口的类,此时可以通过匿名类来实现这一功能。示例代码如下:

Thread thread = new Thread(new Runnable() {
  @Override
  public void run() {
    System.out.println("Hello, Anonymous Class!");
  }
});

thread.start();

事实上,匿名类经过javac编译后,通过字节码分析可以看出,匿名类实际上就是采用内部类的实现方式:以Main.java作为源文件名,Javac首先生成Main$1.class,这个类实现了Runnable接口,并用final 修饰,最终Main.class使用了这个类作为内部类

...
  public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=4, locals=2, args_size=1
         0: new           #2                  // class java/lang/Thread
         3: dup
         4: new           #3                  // class Main$1
         7: dup
         8: invokespecial #4                  // Method Main$1."<init>":()V
        11: invokespecial #5                  // Method java/lang/Thread."<init>":(Ljava/lang/Runnable;)V
...

上述例子中Runnable接口只有一个方法。Java使用**单一抽象方法(Single Abstract Method, SAM)**来表示行为,常见的例如Runnable接口、Callable接口、Comparable接口等,这些接口或类都只有一个方法,表示一个独立的行为。每次都去实现或者实例化这些行为,并不是值得推荐的方式。在过去,Java经常使用上述匿名类的方式。

但是从以上的例子可以看出,要编写某个接口的行为时,我们必须要书写大量的样板代码。而且参差的缩进、冗长的结构,使得代码可读性大大降低。而Java 8 Lambda则提供了一种轻巧的行为传递方式:

Thread thread = new Thread( () -> System.out.println("Hello, Lambda!") );
thread.start();

通过javap -v,可以看到,Java 8 Lambda使用了Java 7中添加的新指令invokedynamic,以及配套的MethodHandlesMethodHandleMethodTypeCallSite实现动态调用:

...
  public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=3, locals=2, args_size=1
         0: new           #2                  // class java/lang/Thread
         3: dup
         4: invokedynamic #3,  0              // InvokeDynamic #0:run:()Ljava/lang/Runnable;
...

传递表达式与方法引用

这种将Lambda表达式直接当作参数传入方法的方式称为传递表达式(pass-through lambda)。使用lambda代替匿名类往往是个不错的注意,但如果我们没有办法一眼看出lambda表达式的作用,又或者代码中充斥含义不明的lambda,那么Lambda化简代码书写的意义还存在吗?事实上,Java 8还提供了另外一种行为传递的方式,方法引用(Method Reference)。方法引用允许我们像往常一样书写类和成员方法,同时允许直接将方法作为参数直接传入另一个方法中。

方法引用允许在传递给方法时直接传入方法名,考虑以下表格的示例,lambda表达式都可以改写成方法引用的形式:

lambda表达式说明改写成方法引用
artist -> search(artist) lambda内使用调用者的内部方法this::search
artist -> Artist.toAbbr(artist)lambda内使用参数类的静态方法Article::toAbbr
artist -> artist.getName()lambda内使用了参数的实例方法Article::getName
(name, country) -> new Artist(name, country)lambda内使用了构造方法Artist::new
(artist, country) -> artist.isBornIn(country)参数既出现在方法名的左边,又出现在方法名的右边Artist::isBornIn

可以看出,方法引用同样是一种简洁的表达方式。上述表格的示例其实并不难理解:当我们使用::索引到方法名时,该方法总会将lambda的形参依次传入自己的参数中;值得注意的是,实例的方法第一个参数总是该实例本身,其次才是方法声明的参数,而类的静态方法则不会将实例传入参数中,事实上,Java在内部实现上就有着类似Python实例方法使用显式self作为第一个参数传入的方式,只是在语法替我们隐藏了而已。

我们可以看到,Lambda匿名表达式虽然简单、方便,但完美的Lambda应该只有一行。比起方法引用,Lambda依然有以下不足:

  1. Lambda缺少文档化的支持,而方法引用可以正常在类中填充文档;
  2. Lambda缺少行为的含义描述,而方法引用的命名就可以指代行为的意义;
  3. 重复的Lambda并不能得到代码复用,而方法引用可以复用代码。

Java中闭包的实现

闭包允许函数将自身运行环境的状态同某个不可见的变量绑定起来,在JavaScript中,闭包是非常常见的设计模式。Java中,我们也可以通过返回一个lambda定义的接口,将需要的环境包裹到函数中,跟随返回的lambda传递到函数外部。

public class Printer {

  public static Runnable print() {
    String location = "World";  // Reference Type
    int value = 2;              // Primtive Type
    Runnable runnable = () -> System.out.println("Hello " + location + value);
    return runnable;
  }
  
  public static void main(String[] args) {
    Runnable runnable = Printer.print();
    runnable.run();
  }
}

但是Java对闭包的要求非常严格:闭包包裹的变量必须是不可变的。如果使用匿名类实现,那么上例中的location必须用final修饰;lambda中虽然不做此要求,但如果尝试在print函数内部改变location的值,则会出现编译错误。这也是有些人质疑Java是否真的实现闭包的原因之一。如果是引用类型,那么如果引用不变,而引用的值发生变化,那么这种改变是被允许的。

现在会有这样的问题:闭包是如何携带了状态的呢?仔细思考一下,JVM是栈模式运行的,方法嵌套则栈不断增高。可能就会这样的情况:print的lambda会在高级别的栈运行,并由print返回到低级别的栈main中,而在mian中又存在更多嵌套的函数,使用了lambda的方法,那么这个lambda将会运行在更高级别的栈上。那么lambda携带的状态location还能否跨栈调用呢?

事实上是不能的。Java在编译时,就将闭包依赖的环境变量写入到方法的字节码中,Java在字节码中会首先将需要的变量依次存储起来。通过javap,我们可以很清除地看到这一点:

public static java.lang.Runnable print();
    descriptor: ()Ljava/lang/Runnable;
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=2, locals=3, args_size=0
         0: ldc           #2                  // String World
         2: astore_0
         3: iconst_2
         4: istore_1
         5: aload_0
         6: iload_1
         7: invokedynamic #3,  0              // InvokeDynamic #0:run:(Ljava/lang/String;I)Ljava/lang/Runnable;
        12: astore_2
        13: aload_2
        14: areturn

函数接口

函数接口注释@FunctionalInterface

虽然通过定义单一抽象方法,就可以在代码中使用lambda表达式,但并不是每个只包含一个函数的接口或类都表示某个行为,例如Comparable、Closable。因此,对于函数接口,Java 8中要求使用注释@FunctionalInterface对每个表示函数的函数接口进行修饰,该注释会在编译期强制检查接口是否符合函数接口的标准,从而很容易定位问题。

@Documented
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
public @interface FunctionalInterface {}

通过查看注释@FunctionalInterface的源代码,可知该注释需要作用在接口或者类上,并且在JavaDoc和JVM运行时都会保留这个注解。

接口的默认方法与静态方法

Java 8为lambda增添了很多实用的功能,例如为Collection提供了Stream流式的数据处理方法。但这会给兼容性带来不小的难题:如果自定义实现了List、Map的类,那么一旦升级到Java 8,就会因为没有实现stream的方法而导致编译错误。Java 8采用了默认方法的方式来解决Collection接口的兼容性问题,在方法最左端添加default关键字,便可以在接口中实现该方法。

// java.lang.Iterable
public interface Iterable<T> {

    Iterator<T> iterator();

    default void forEach(Consumer<? super T> action) {
        Objects.requireNonNull(action);
        for (T t : this) {
            action.accept(t);
        }
    }
    
    default Spliterator<T> spliterator() {
        return Spliterators.spliteratorUnknownSize(iterator(), 0);
    }
}

// java.lang.Collection
public interface Collection<E> extends Iterable<E> {
  // ...
  default Stream<E> stream() {
    return StreamSupport.stream(spliterator(), false);
  }
}

由于接口没有成员变量,因此默认方法只能通过调用子类的方法修改子类本身。在Collection.stream方法的默认实现中,则调用了父接口spliterator的默认实现。同时,这种默认方法本身也可以被子类或子接口覆盖。这种默认方法相当于为接口实现了默认的行为,当某个接口具有同样的行为特征时,可以使用接口的默认方法而不必使用继承来实现了。

但是,*《Effective Java》*这本书对默认方法的态度是非常保守的:使用默认方法可能会导致接口的实现在没有错误或警告的情况下编译,而在运行时出现异常。书中认为,默认接口就是为了解决兼容性问题(事实上也的确如此),应该避免在现有接口中添加新的默认方法。

Java 8中添加了新的语言特性:可以在接口中实现静态方法。以往的最佳实践是采用静态工厂方法,为某个类设计一个复数形式的工具类,这个类中只包含很多静态方法,例如Object对应的工具类为Objects、Collection对应的工具类为Collections。如果一个方法有充分的语义和某个概念相关,那么就应该将该方法和相关的类或者接口放在一起,而不是放在工具类中,例如Stream中的of方法,可以将参数转化成stream流的形式,进而使用流处理的方法。

public interface Stream<T> extends BaseStream<T, Stream<T>> {
  // ...
  public static<T> Stream<T> of(T t) {
    return StreamSupport.stream(new Streams.StreamBuilderImpl<>(t), false);
  }
}

值得注意的是,虽然@FunctionalInterface修饰的函数接口只能包含一个方法,但是对于默认方法和静态方法则没有任何数量限制,并且在客户端也可以通过类或者实例使用这些方法。在java.util.function中,有相当多的函数接口同时使用了默认方法和静态方法,例如Function、Predicate等。我们自定义一个函数接口:

@FunctionalInterface
public interface SAMInterface {

  void generalFunction ();

  default void defaultFunction(){};

  static void staticFunction(){};
}

通过javac与javap,可以看到该函数接口的方法生成了一个抽象方法,而默认方法则变成了普通方法,静态方法变成了类的静态方法。除了缺少一个构造函数,该接口与直接使用抽象类生成的结构是非常类似的。

  public abstract void generalFunction();
    descriptor: ()V
    flags: ACC_PUBLIC, ACC_ABSTRACT

  public void defaultFunction();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=0, locals=1, args_size=1
         0: return
      LineNumberTable:
        line 6: 0

  public static void staticFunction();
    descriptor: ()V
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=0, locals=0, args_size=0
         0: return
      LineNumberTable:
        line 8: 0

内置函数接口

JDK为函数接口提供了丰富的实现,绝大部分情况下,我们都不需要自定义函数接口。在包java.util.function中,一共提供了43个泛型或基本类型的函数接口,可供我们直接使用。而对于我们使用者而言,只需要记住6个基本的函数接口,余下的接口都会非常容易的猜测出来:

Java 8 基本内置函数接口

  1. 单一泛型的函数接口,包括UnaryOperator、BinaryOperator、Predicate、Supplier、Consumer。Java要求泛型不能是基本类型,但是装箱拆箱会导致一定的性能损失,因此java.util.function额外为每种基本类型(int、long、double)实现了上述5个内置接口,例如DoublePredicate、IntSupplier、LongUnaryOperator等,以及一个返回boolean型的BooleanSupplier接口,一共16个。
  2. 双泛型的函数接口,指的是Function。与上同理,java.util.function同样额外提供了关于基本类型(int、long、double)之间、或者与泛型之间相互转换的内置接口,例如DoubleToIntFunction、IntToLongFunction、IntFunction、DoubleFunction、ToLongFunction、ToIntFunction等一共12个。
  3. 接受一个参数的函数接口,包括Predicate、Function、Consumer(这里不包括只能接受一个的UnaryOperator)。java.util.function提供了双泛型参数的版本,例如BiPredicate、BiFunction、BiConsumer一共3个。同时为BiFunction提供了返回基本类型的函数接口,ToIntBiFunction、ToDoubleBiFunction、ToLongBiFunction一共3个。对于Consumer则额外提供了接受单个泛型和一个基本类型参数的函数接口,包括ObjDoubleConsumer、ObjIntConsumer、ObjLongConsumer一共3个。
  4. 6 + 16 + 12 + (3 + 3 + 3) = 43,这样就记下了java.util.function提供的所有内置接口。

参考资料

[1] Richard W. Java 8 函数式编程[M]. 王群锋
[2] Joshua B. Effective Java 3rd Edition
[3] Venkat S. Java 8 idioms

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值