【问题标题】:Why does a lambda need to capture the enclosing instance when referencing a final String field?为什么 lambda 在引用 final String 字段时需要捕获封闭实例?
【发布时间】:2016-03-23 21:19:23
【问题描述】:

这是基于this question。考虑以下示例,其中方法基于 lambda 表达式返回 Consumer

public class TestClass {
    public static void main(String[] args) {
        MyClass m = new MyClass();
        Consumer<String> fn = m.getConsumer();

        System.out.println("Just to put a breakpoint");
    }
}

class MyClass {
    final String foo = "foo";

    public Consumer<String> getConsumer() {
        return bar -> System.out.println(bar + foo);
    }
}

正如我们所知,在进行函数式编程时引用 lambda 中的当前状态并不是一个好习惯,原因之一是 lambda 会捕获封闭的实例,在 lambda 本身退出之前不会被垃圾收集范围。

然而,在这个与 final 字符串相关的特定场景中,编译器似乎可以在返回的 lambda 中包含常量 (final) 字符串 foo(来自常量池),而不是包含调试时整个MyClass 实例如下所示(将中断放置在System.out.println)。是否与将 lambda 编译为特殊的 invokedynamic 字节码的方式有关?

【问题讨论】:

  • 你能澄清一下吗?你首先解释说你期望封闭的MyClass 实例,然后你很惊讶地看到这个args$1
  • @JeanLogaert 问题是为什么在使用最终变量时仍然会发生
  • @JeanLogeart 这个问题专门针对final String,它被认为是一个常数。
  • " 在进行函数式编程时引用 lambda 中的当前状态不是一个好习惯,因为 lambda 会捕获封闭的实例,在 lambda 本身超出范围之前不会对其进行垃圾收集。”这与函数式编程无关。它与优化有关。函数式编程的观点是关闭可变状态是一种不好的做法。闭包延长捕获的生命周期这一事实是它们工作方式的重要组成部分。

标签: java string lambda java-8


【解决方案1】:

在您的代码中,bar + foo 实际上是 bar + this.foo 的简写;我们只是习惯了速记,以至于忘记了我们正在隐式获取实例成员。所以你的 lambda 捕获的是this,而不是this.foo

如果您的问题是“能否以不同的方式实现此功能”,答案是“可能是的”;我们可以任意地使 lambda 捕获的规范/实现变得更加复杂,以便为包括这种情况在内的各种特殊情况提供渐进式更好的性能。

更改规范以捕获this.foo 而不是this 不会对性能产生太大影响;它仍然是一个捕获 lambda,这比额外的字段取消引用要考虑更多的成本。所以我不认为这会带来真正的性能提升。

【讨论】:

  • 感谢您的回答!理由是它可以减少潜在“泄漏”的数量,其中 lambdas 保留对封闭实例的引用(因为 Java 8 越来越多地使用),只在字段不是 final 时封闭实例。
  • 仅供参考,几年前 Neal 曾考虑本着这种精神对 C# lambda 捕获代码进行一些更改;我不知道他是否曾经这样做过。
  • 还要注意,这也需要仔细权衡。当 lambda 有一个对 this.x 的引用时,这一点很明显,但是两个呢?三? 96?在某些时候,捕获额外变量的“治疗”比疾病更糟糕。
  • 对不起,这个解释没有抓住重点。在分析编译后的代码时,我们会发现它确实使用了foo的编译时常量值,如果实例变量foo是问题中的编译时常量。这是一件好事,因为这与对该变量的所有其他访问一致,而且我们根本不是在谈论“优化”。有趣的是,尽管没有使用也不需要它,它仍然捕获this。也许是为了满足规范,但请注意, 规范没有提及任何字段/this 捕获规则。或者我找不到它。
【解决方案2】:

如果 lambda 捕获的是 foo 而不是 this,在某些情况下您可能会得到不同的结果。考虑以下示例:

public class TestClass {
    public static void main(String[] args) {
        MyClass m = new MyClass();
        m.consumer.accept("bar2");
    }
}

class MyClass {
    final String foo;
    final Consumer<String> consumer;

    public MyClass() {
        consumer = getConsumer();
        // first call to illustrate the value that would have been captured
        consumer.accept("bar1");
        foo = "foo";
    }

    public Consumer<String> getConsumer() {
        return bar -> System.out.println(bar + foo);
    }
}

输出:

bar1null
bar2foo

如果 foo 被 lambda 捕获,它将被捕获为 null,第二次调用将打印 bar2null。但是,由于捕获了 MyClass 实例,它会打印正确的值。

当然,这是丑陋的代码,有点做作,但在更复杂的实际代码中,可能有点容易发生这样的问题。

请注意,唯一真正的事情是,我们通过消费者强制读取构造函数中待分配的foo。构建消费者本身时预计不会读取foo,因此在分配foo之前构建它仍然是合法的——只要你不立即使用它。

但是编译器不会让您在分配 foo 之前在构造函数中初始化相同的 consumer - 可能是最好的 :-)

【讨论】:

  • 这很有趣,但它看起来更像是代码本身的错误。
  • @AR.3 bar1null 输出可能会被视为代码中的错误,但我认为您不会期望第二次调用打印bar2null:这将被视为错误在 JVM 中。
  • 我同意你的看法。然而,如果有一天,final 字段在 lambda 中的捕获方式不同,那么在输出中看到两个 bar1null 一点也不奇怪。
  • 在本例中,foo 不是编译时常量。所以这并不是问题所在。
【解决方案3】:

你是对的,它在技术上可以这样做,因为有问题的字段是final,但它不是。

但是,如果返回的 lambda 保留对 MyClass 实例的引用是一个问题,那么您可以轻松地自己修复它:

public Consumer<String> getConsumer() {
    String f = this.foo;
    return bar -> System.out.println(bar + f);
}

请注意,如果该字段不是 final,那么您的原始代码将使用 lambda 执行时的实际值,而此处列出的代码将使用 @987654324 时的值@方法被执行。

【讨论】:

  • 严格来说这并不正确。正如其他答案所指出的,由于实例成员的初始化顺序,语义可能会有所不同。此外,尽管该字段实际上是最终的,但 lambda 实际上仍然引用了调用 getConsumer 时的实际值。最后的不变性是偶然的。
【解决方案4】:

请注意,对于作为编译时常量的变量的任何普通 Java 访问,都会发生常量值,因此,与某些人声称的不同,它不受初始化顺序问题的影响。

我们可以通过下面的例子来证明这一点:

abstract class Base {
    Base() {
        // bad coding style don't do this in real code
        printValues();
    }
    void printValues() {
        System.out.println("var1 read: "+getVar1());
        System.out.println("var2 read: "+getVar2());
        System.out.println("var1 via lambda: "+supplier1().get());
        System.out.println("var2 via lambda: "+supplier2().get());
    }
    abstract String getVar1();
    abstract String getVar2();
    abstract Supplier<String> supplier1();
    abstract Supplier<String> supplier2();
}
public class ConstantInitialization extends Base {
    final String realConstant = "a constant";
    final String justFinalVar; { justFinalVar = "a final value"; }

    ConstantInitialization() {
        System.out.println("after initialization:");
        printValues();
    }
    @Override String getVar1() {
        return realConstant;
    }
    @Override String getVar2() {
        return justFinalVar;
    }
    @Override Supplier<String> supplier1() {
        return () -> realConstant;
    }
    @Override Supplier<String> supplier2() {
        return () -> justFinalVar;
    }
    public static void main(String[] args) {
        new ConstantInitialization();
    }
}

打印出来:

var1 read: a constant
var2 read: null
var1 via lambda: a constant
var2 via lambda: null
after initialization:
var1 read: a constant
var2 read: a final value
var1 via lambda: a constant
var2 via lambda: a final value

因此,如您所见,在执行超级构造函数时尚未发生对 realConstant 字段的写入这一事实,即使通过访问它,也不会看到真正的编译时常量的未初始化值拉姆达表达式。从技术上讲,因为该字段实际上并没有被读取。

此外,出于同样的原因,讨厌的反射黑客对普通 Java 对编译时常量的访问没有影响。读取这种修改后的值的唯一方法是通过反射:

public class TestCapture {
    static class MyClass {
        final String foo = "foo";
        private Consumer<String> getFn() {
          //final String localFoo = foo;
          return bar -> System.out.println("lambda: " + bar + foo);
        }
    }
    public static void main(String[] args) throws ReflectiveOperationException {
        final MyClass obj = new MyClass();
        Consumer<String> fn = obj.getFn();
        // change the final field obj.foo
        Field foo=obj.getClass().getDeclaredFields()[0];
        foo.setAccessible(true);
        foo.set(obj, "bar");
        // prove that our lambda expression doesn't read the modified foo
        fn.accept("");
        // show that it captured obj
        Field capturedThis=fn.getClass().getDeclaredFields()[0];
        capturedThis.setAccessible(true);
        System.out.println("captured obj: "+(obj==capturedThis.get(fn)));
        // and obj.foo contains "bar" when actually read
        System.out.println("via Reflection: "+foo.get(capturedThis.get(fn)));
        // but no ordinary Java access will actually read it
        System.out.println("ordinary field access: "+obj.foo);
    }
}

打印出来:

lambda: foo
captured obj: true
via Reflection: bar
ordinary field access: foo

这向我们展示了两件事,

  1. 反射对编译时常量也没有影响
  2. 周围的物体已被捕获,尽管它不会被使用

我很乐意找到这样的解释,“对实例字段的任何访问都需要 lambda 表达式来捕获该字段的实例(即使该字段实际上并未被读取)” ,但不幸的是,我在当前的 Java 语言规范中找不到关于捕获值或 this任何 语句,这有点可怕:

我们已经习惯了这样一个事实:访问 lambda 表达式中的实例字段将创建一个没有对 this 的引用的实例,但即使这样实际上也不能保证当前规范。重要的是要尽快修复这个遗漏……

【讨论】:

    猜你喜欢
    • 2016-02-18
    • 2017-11-08
    • 1970-01-01
    • 1970-01-01
    • 2021-10-14
    • 2011-05-16
    • 2018-04-20
    • 2019-07-19
    • 2013-10-11
    相关资源
    最近更新 更多