在本文中,我们将展示一些在 Java 8 中不太为人所了解的 Lambda 表达式技巧及其使用限制。本文的主要的受众是 Java 开发人员,研究人员以及工具库的编写人员。 这里我们只会使用没有 com.sun 或其他内部类的公共 Java API,如此代码就可以在不同的 JVM 实现之间进行移植。
快速介绍
Lambda 表达式作为在 Java 8 中实现匿名方法的一种途径而被引入,可以在某些场景中作为匿名类的替代方案。 在字节码的层面上来看,Lambda 表达式被替换成了 invokedynamic 指令。这样的指令曾被用来创建功能接口的实现。 而单个方法则是利用 Lambda 里面所定义的代码将调用委托给实际方法。
例如,我们手头有如下代码:
这段代码被 Java 编译器翻译过来就成了下面这样:
invokedynamic 指令可以用 Java 代码粗略的表示成下面这样:
正如你所看见的,LambdaMetafactory 被用来生成一个调用站点,用目标方法句柄来表示一个工厂方法。这个工厂方法使用了 invokeExact 来返回功能接口的实现。如果 Lambda 封装了变量,则 invokeExact 会接收这些变量拿来作为实参。
在 Oracle 的 JRE 8 中,metafactory 会利用 ObjectWeb Asm 来动态地生成 Java 类,其实现了一个功能接口。 如果 Lambda 表达式封装了外部变量,生成的类里面就会有额外的域被添加进来。这种方法类似于 Java 语言中的匿名类 —— 但是有如下区别:
匿名类是在编译时由 Java 编译器生成的。Lambda 实现的类则是由 JVM 在运行时生成。metafactory 的如何实现要看是什么 JVM 供应商和版本
当然,invokedynamic 指令并不是专门给 Java 中的 lambda 表达式来使用的。引入该指令主要是为了可以在 JVM 之上运行的动态语言。Java 所提供的 Nashorn JavaScript 引擎开箱即用,就大大地利用了该指令。
在本文的后续内容中,我们将重点介绍 LambdaMetafactory 类及其功能。本文的下一节将假设你已经完全了解了 metafactory 方法如何工作以及 MethodHandle 是什么。
Lambdas 小技巧
在本节中,我们将介绍如何使用 lambdas 动态构建日常任务。
检查异常和 Lambdas
我们都知道,Java 提供的所有函数接口不支持检查异常。检查与未检查异常在 Java 中打着持久战。
如果你想使用与 Java Streams 结合使用的 lambdas 内的检查异常的代码呢? 例如,我们需要将字符串列表转换成 URL 列表,如下所示:
URL(String)已经在 throws 地方声明了一个检查的异常,因此它不能直接用作 Function 的方法引用。
你说“是的,这里可以使用这样的技巧”:
这是一个很挫的做法。原因如下:
使用 try-catch 块重新抛出异常Java 中类型擦除的使用不足这个问题被使用以下方式可以更“合法”的方式解决:
检查的异常仅由 Java 编程语言的编译器识别throws 部分只是方法的元数据,在 JVM 级别没有语义含义检查和未检查的异常在字节码和 JVM 级别是不可区分的解决的办法是只把 Callable.call 的调用封装在不带 throws 部分的方法之中:
这段代码不会被 Java 编译器编译通过,因为方法 Callable.call 在其 throws 部分有受检异常。但是我们可以使用动态构造的 lambda 表达式擦除这个部分。
首先,我们要声明一个函数式接口,没有 throws 部分但能够委派调用给 Callable.call:
第二步是使用 LambdaMetafactory 创建这个接口的实现,以及委派 SilentInvoker.invoke 的方法调用给方法 Callable.call。如前所述,在字节码的级别上 throws 部分被忽略,因此,方法 SilentInvoker.invoke 能够调用方法 Callable.call 而无需声明受检异常:
第三,写一个实用方法,调用 Callable.call 而不声明受检异常:
现在,我们可以毫无顾忌地重写我们的流,使用异常检查:
此代码将成功编译,因为 callUnchecked 没有被声明为需要检查异常。此外,使用单态内联缓存时可以内联式调用此方法,因为在 JVM 中只有一个实现 SilentInvoker 接口的类。
如果实现的 Callable.call 在运行时抛出一些异常,只要它们被捕捉到就没什么问题。
尽管有这样的方法来实现功能,但还是推荐下面的用法:
只有当调用代码保证不存在异常时,才能隐藏已检查的异常,才能调用相应的代码。
下面的例子演示了这种方法:
这个方法是这个工具的完整实现,在这里它作为开源项目SNAMP的一部分。
使用 Getter 和 Setter
这一节对不同数据格式(如 JSON,Thrift 等等)的序列化/反序列化的编写工具有用。此外,如果你的代码严重依赖了为 JavaBean 的 getter 和 setter 准备的 Java 反射,那么它及其有用。
getter 是在 JavaBean 中的一个使用 getXXX 命名的无参且非 Void 返回类型的方法、setter 是在 JavaBea n中的一个使用 setXXX 命名的有一个单独参数并返回 void 类型的方法。这两个符号可以被表示为函数的接口:
一个 getter 可以被表示为一个函数的参数是 this 引用的 Function 。一个 setter 可以被表示为一个第一个参数是 this 引用,第而个参数是被传进 setter 的值的 BiConsumer。现在,我们创建两个方法,这两个方法可以把任何 getter 或 setter 转换为这些函数的接口。无论这两个函数接口是不是通用。只要类型擦除掉之后,真实的类型都等于一个对象。自动的构造一个返回类型和可以被 LambdaMetafactory 识别的参数。此外,uava's Cache 帮助为相同的 getter 或 setter 缓存 lambdas 。
首先,必须为 getter 和 setter 声明一个缓存。从 Reflection API 上看,Method 表示一个真实的 getter 或 setter,并且做为一个 Key 被使用。在缓存中的值代表对于特定的 getter 或 setter 的动态构造函数接口。
其次,创建工厂方法通过从方法句柄中指向一个 getter 或 setter 来创建一个函数接口的实例:
通过 samMethodType 和 instantiatedMethodType(分别为方法 metafactory 的第三个和第五个参数)之间的区别,可以实现类型擦除后的函数接口中基于对象的参数和实际参数类型之间的自动转换以及 getter 或 setter 中的返回类型。实例化的方法类型是提供专门的 lambda 的方法实现。
然后,为这些工厂具有缓存的支持,创建一个门面:
作为使用 Java 反射 API 的 Method 实例,获取的方法信息可以轻松地转换为 MethodHandle。考虑到实例方法总是有隐藏的第一个参数用于将其传递给方法。静态方法没有这些隐藏的参数。例如,Integer.intValue()方法具有 int intValue 的实际签名(Integer this)。这个技巧用于实现 getter 和 setter 的功能包装器。
现在是时候测试代码了:
这种使用缓存的 getter 和 setter 的方法可以在诸如 Jackson 这样的序列化和反序列化库中高效的使用,这些库在序列化/反序列化库的过程中使用 getter 和 setter。
使用 LambdaMetafactory 来动态生成的实现调用函数接口比通过 Java Reflection API 的调用快 得多
你可以在这里找到完整的代码,它是开源项目 SNAMP 的一部分。
限制和缺陷
在本节中,我们将给出在 Java 编译器和 JVM 中与 lambdas 相关的一些错误和限制。 所有这些限制都可以在 OpenJDK 和 Oracle JDK 上重现,它们适用于 Windows 和 Linux 的 javac 1.8.0_131。
从方法句柄构建 Lambdas
如你所知,可以使用 LambdaMetafactory 动态构建 lambda。要实现这一点,你应该指定一个 MethodHandle,其中包含一个由函数接口声明的单个方法的实现。我们来看看这个简单的例子:
上面代码等价于:
但如果我们用一个可以表示一个字段获取方法的方法处理器来替换指向 getValue 的方法处理器的话,情况会如何呢:
该代码应该是可以按照预期来运行的,因为 findGetter 会返回一个指向字段获取方法、并且具备有效签名的方法处理器。 但是如果你运行了代码,就会看到如下异常:
有趣的是,如果我们使用 MethodHandleProxies,字段获取方法却可以运行得很好:
要注意 MethodHandleProxies 并非动态创建 lambda 表达式的理想方法,因为这个类只是把 MethodHandle 封装到一个代理类里面,然后把对 InvocationHandler.invoke 的调用指派给了 MethodHandle.invokeWithArguments 方法。 这种方法使得 Java 反射机制运行起来非常的慢。
如前所述,并不是所有的方法句柄都可以在运行时用于构建 lambdas。
只有几种与方法相关的方法句柄可以用于 lambda 表达式的动态构造
这包括:
REF_invokeInterface: 对于接口方法可通过 Lookup.findVirtual 来构建REF_invokeVirtual: 对于由类提供的虚方法可以通过 Lookup.findVirtual 来构建REF_invokeStatic: 对于静态方法可通过 Lookup.findStatic 构建REF_newInvokeSpecial: 对于构造函数可通过 Lookup.findConstructor 构建REF_invokeSpecial: 对于私有方法和由类提供的早绑定的虚方法可通过 Lookup.findSpecial 构建其他方法的句柄将会触发 LambdaConversionException 异常。
泛型异常
这个 bug 与 Java 编译器以及在 throws 部分声明泛型异常的能力有关。下面的示例代码演示了这种行为:
这段代码应该编译成功因为 URL 构造器抛出 MalformedURLException。但事实并非如此。编译器产生以下错误消息:
但如果我们用一个匿名类替换 lambda 表达式,那么代码就编译成功了:
结论很简单:
当与 lambda 表达式配合使用时,泛型异常的类型推断不能正确工作。
泛型边界
一个带有多个边界的泛型可以用 & 号构造:<T extends A & B & C & ... Z>。这种泛型参数定义很少被使用,但由于其局限性,它对 Java 中的 lambda 表达式有某些影响:
每一个边界,除了第一个边界,都必须是一个接口。具有这种泛型的类的原始版本只考虑了约束中的第一个边界。第二个局限性使 Java 编译器在编译时和 JVM 在运行时产生不同的行为,当 Lambda 表达式的联动发生时。可以使用以下代码重现此行为:
这段代码绝对没错,而且用 Java 编译器编译也会成功。MutableInteger 这个类可以满足泛型 T 的多个类型绑定约束:
MutableInteger 是从 Number 继承的MutableInteger 实现了 IntSupplier但是在运行的时候会抛出异常:
之所以会这样是因为 Java Stream 的管道只捕获到了一个原始类型,它是一个 Number 类。Number 类本身并没有实现 IntSupplier 接口。 要修复此问题,可以在一个作为方法引用的单独方法中明确定义一个参数类型:
这个示例就演示了 Java 编译器和运行时所进行的一次不正确的类型推断。
在 Java 中的编译时和运行时处理与 lambdas 结合的多个类型绑定会导致不兼容。
作者:OSC-协作翻译
来源:51CTO
相关资源:七夕情人节表白HTML源码(两款)