博客
关于我
强烈建议你试试无所不能的chatGPT,快点击我
Java 8 函数式编程入门之Lambda
阅读量:2439 次
发布时间:2019-05-10

本文共 8869 字,大约阅读时间需要 29 分钟。

前言

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."
":()V 11: invokespecial #5 // Method java/lang/Thread."
":(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.Iterablepublic interface Iterable
{
Iterator
iterator(); default void forEach(Consumer
action) { Objects.requireNonNull(action); for (T t : this) { action.accept(t); } } default Spliterator
spliterator() { return Spliterators.spliteratorUnknownSize(iterator(), 0); }}// java.lang.Collectionpublic interface Collection
extends Iterable
{
// ... default Stream
stream() { return StreamSupport.stream(spliterator(), false); }}

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

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

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

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

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

@FunctionalInterfacepublic 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.

转载地址:http://azdqb.baihongyu.com/

你可能感兴趣的文章
窗口怎么退出扩展窗口_如何创建退出意图弹出窗口
查看>>
docker删除映像_如何将更改提交到Docker映像
查看>>
本地ssl证书生成_如何生成本地SSL证书
查看>>
c语言中的null_如何在C中使用NULL
查看>>
如何在macOS中安装本地SSL证书
查看>>
axios 请求嵌入请求_如何对每个Axios请求强制使用凭据
查看>>
检查node.js版本_如何在运行时检查当前的Node.js版本
查看>>
如何安装svelte_如何在Svelte模板中模拟for循环
查看>>
jsp打印画布_如何将画布打印到数据URL
查看>>
javascript 逗号_如何使用JavaScript将逗号更改为点
查看>>
在JavaScript中,如何判断值的类型?
查看>>
docker删除映像_从命令行使用Docker映像
查看>>
npm 云函数_如何在Netlify函数中使用npm软件包
查看>>
使用Docker Desktop管理容器
查看>>
如何在JavaScript中交换两个数组元素
查看>>
小程序 画布未加载_如何在HTML画布中加载图像
查看>>
如果Docker容器立即退出该怎么办
查看>>
如何使用JavaScript将图像添加到DOM
查看>>
docker 容器部署_根据Docker映像更新已部署的容器
查看>>
如何随机播放JavaScript数组中的元素
查看>>