非捕获Lambda的实例

大约一个月前,我在Java 8的lambda表达式框架下总结了Brian Goetz的观点 目前,我正在研究有关默认方法的文章,令我惊讶的是,我又回到了Java处理lambda表达式的方式。 这两个功能的交集可能会产生微妙但令人惊讶的效果,我想讨论一下。

总览

为了使这一点更有趣,我将以一个示例开头,该示例将以我的个人WTF达到顶峰 时刻。 完整的示例可以在专用的GitHub项目中找到

然后,我们将看到有关此意外行为的解释,并最终得出一些预防错误的结论。

这里有个例子……它不是那么简单或抽象,因为我希望它显示这种情况的相关性。 但是从某种意义上说,它仍然只是一个示例,它仅暗示可能实际上会做一些有用的事情的代码。

功能界面

假设对于在构建期间结果已经存在的情况,我们需要Future接口进行特殊化。

我们决定通过创建一个接口ImmediateFuture来实现此目的,该接口get()使用默认方法实现除get()之外的所有功能。 这导致功能界面

您可以在此处查看源代码。

一个工厂

接下来,我们实现FutureFactory 它可能创建各种期货,但肯定会创建我们的新子类型。 它是这样的:

未来工厂

/**
 * Creates a new future with the default result.
 */
public static Future<Integer> createWithDefaultResult() {
	ImmediateFuture<Integer> immediateFuture = () -> 0;
	return immediateFuture;
}

/**
 * Creates a new future with the specified result.
 */
public static Future<Integer> createWithResult(Integer result) {
	ImmediateFuture<Integer> immediateFuture = () -> result;
	return immediateFuture;
}

创造未来

最后,我们使用工厂创建一些期货并将其收集在一组中:

创建实例

public static void main(String[] args) {
	Set<Future<?>> futures = new HashSet<>();

	futures.add(FutureFactory.createWithDefaultResult());
	futures.add(FutureFactory.createWithDefaultResult());
	futures.add(FutureFactory.createWithResult(42));
	futures.add(FutureFactory.createWithResult(63));

	System.out.println(futures.size());
}

WTF ?!

运行程序。 控制台会说...

4? 不。 3。

WTF ?!

Lambda表达式的评估

那么这是怎么回事? 那么,与有关lambda表达式的评估一些背景知识,这其实并不奇怪。 如果您不太熟悉Java的实现方式,那么现在是赶上Java的好时机。 这样做的一种方法是观看Brian Goetz的演讲“ Java中的Lambdas:深入了解”或阅读我的摘要

实例非捕获lambdas

Lambda表达式的实例

理解这种行为的关键在于,事实是JRE不保证如何将lambda表达式转换为相应接口的实例。 让我们看一下Java语言规范对此事的看法:

15.27.4。 Lambda表达式的运行时评估

[…]

分配并初始化具有以下属性的类的新实例,或者引用具有以下属性的类的现有实例。

[…类的属性–这里不足为奇…]

这些规则旨在通过以下方式为Java编程语言的实现提供灵活性:

  • 不必在每次评估中分配一个新对象。
  • 由不同的lambda表达式产生的对象不必属于不同的类(例如,如果主体相同)。
  • 评估产生的每个对象不必属于同一类(例如,可以内联捕获的局部变量)。
  • 如果“现有实例”可用,则无需在先前的lambda评估中创建它(例如,可能在封闭类的初始化期间分配了它)。
[…]

JLS,Java SE 8版,§15.27.4

在其他优化中,这显然使JRE可以返回相同的实例,以重复评估lambda表达式。

非捕获Lambda表达式的实例

请注意,在上面的示例中,表达式不捕获任何变量。 因此,它永远不会因评估而改变。 而且由于lambda并非设计为具有状态,因此不同的评估在其生命周期中也无法“分散”。 因此,一般而言,没有充分的理由来创建多个不捕获的lambda实例,因为它们在整个生命周期中都完全相同。 这样可以使优化始终返回相同的实例。

(将其与捕获某些变量的lambda表达式进行对比。对此表达式的直接评估是创建一个将捕获的变量作为字段的类。然后,每个评估都必须创建一个新实例,将实例存储在其字段中这些情况显然并不完全相同。)

这就是上面代码中发生的事情。 () -> 0是一个不捕获的lambda表达式,因此每个评估都返回相同的实例。 因此,对createWithDefaultResult()每次调用都是如此。

但是,请记住,这仅适用于当前安装在我的计算机上的JRE版本(用于Win 64的Oracle 1.8.0_25-b18)。 您的可以有所不同,下一个gal也可以如此等等。

得到教训

因此,我们了解了为什么会这样。 尽管这很有意义,但我仍然会说这种行为并不明显,因此并不是每个开发人员都期望的。 这是产生错误的温床,因此让我们尝试分析情况并从中学习一些东西。

使用默认方法进行子类型化

可以说,意外行为的根本原因是如何完善Future的决定。 为此,我们扩展了另一个接口,并使用默认方法实现了部分功能。 仅剩一个未实现的方法, ImmediateFuture成为了一个启用lambda表达式的功能接口。

另外, ImmediateFuture可以是抽象类。 这样可以防止工厂意外返回相同的实例,因为它不能使用lambda表达式。

关于抽象类和默认方法的讨论不容易解决,因此我在这里不尝试这样做。 但是,我很快将发布有关默认方法的文章,并且我打算再讲一遍。 可以说,在做出决定时应考虑此处提出的案例。

工厂中的Lambda

由于lambda的引用相等性不可预测,因此工厂方法应仔细考虑使用它们来创建实例。 除非方法的合同明确允许不同的调用返回相同的实例,否则应完全避免使用它们。

我建议在此禁令中包括捕获lambda。 (对我而言)一点也不清楚,在什么情况下同一实例可以在将来的JRE版本中重用。 一种可能的情况是,JIT发现紧密的循环创建了总是(或至少经常)返回同一实例的供应商。 通过用于不捕获lambda的逻辑,重用同一供应商实例将是有效的优化。

匿名类与Lambda表达式

注意匿名类和lambda表达式的不同语义。 前者保证创建新实例,而后者则不能。 为了继续该示例,以下createWithDefaultResult()将导致futures –大小为4的集合:

匿名类的替代实现

public static Future<Integer> createWithDefaultResult() {
	ImmediateFuture<Integer> immediateFuture = new ImmediateFuture<Integer>() {
		@Override
		public Integer get() throws InterruptedException, ExecutionException {
			return 0;
		}
	};
	return immediateFuture;
}

这尤其令人不安,因为许多IDE允许从匿名接口实现到lambda表达式的自动转换,反之亦然。 由于两者之间存在细微的差异,这种看似纯粹的句法转换会带来细微的行为变化。 (我最初并不了解。)

如果您最终遇到了这种情况,并选择使用匿名类,请确保明显记录您的决定! 不幸的是,似乎没有办法阻止Eclipse对其进行任何转换(例如,如果将转换作为保存操作启用),这也会删除匿名类中的所有注释。

最终的选择似乎是一个(静态)嵌套类。 我知道没有IDE敢将其转换为lambda表达式,因此这是最安全的方法。 尽管如此,仍需要对其进行记录,以防止下一个Java-8狂热分子(确实像您一样)出现并加紧您的仔细考虑。

功能接口标识

当您依赖功能接口的标识时要小心。 始终考虑是否有可能,无论您在何处获得这些实例,都可能反复将您交给同一个实例。

但这当然是模糊的,几乎没有什么具体的结果。 首先,所有其他接口都可以简化为功能接口。 这实际上就是我选择Future的原因-我想举个例子,不要立即尖叫疯狂的Lambda狗屎! 其次,这会使您很快变得偏执。

因此,请不要过分考虑-记住这一点。

保证行为

最后但并非最不重要的一点(这始终是正确的,但值得在此重复):

不要依靠无证的行为!

JLS不保证每个lambda评估都返回一个新实例(如上面的代码所示)。 但这并不能保证观察到的行为,即未捕获的lambda始终由同一实例表示。 因此,不要编写依赖于任何一个的代码。

不过,我必须承认这是一个艰难的过程。 认真地说,谁在使用某些功能之前先看过它们的JLS? 我当然不会。

反射

我们已经看到Java不能保证所评估的lambda表达式的身份。 尽管这是一个有效的优化,但它可能会产生令人惊讶的效果。 为了防止这种情况引入细微的错误,我们派生了以下准则:

  • 使用默认方法部分实现接口时要小心。
  • 不要在工厂方法中使用lambda表达式。
  • 当身份重要时,请使用匿名类或更好的内部类。
  • 依赖功能接口的标识时要小心。
  • 最后, 不要依赖未记录的行为!

翻译自: https://www.javacodegeeks.com/2015/01/instances-of-non-capturing-lambdas.html