Java “空白的最终字段可能尚未初始化” 匿名接口 vs Lambda 表达式

我最近遇到了错误消息“空白的最终字段obj可能尚未初始化”。

通常,如果您尝试引用可能尚未分配给值的字段,则会出现这种情况。示例类:

public class Foo {
    private final Object obj;
    public Foo() {
        obj.toString(); // error           (1)
        obj = new Object();
        obj.toString(); // just fine       (2)
    }
}

我使用Eclipse。在行中,我得到错误,在行中一切正常。到目前为止,这是有道理的。(1)(2)

接下来,我尝试在构造函数中创建的匿名接口中进行访问。obj

public class Foo {
    private Object obj;
    public Foo() {
        Runnable run = new Runnable() {
            public void run() {
                obj.toString(); // works fine
            }
        };
        obj = new Object();
        obj.toString(); // works too
    }
}

这也有效,因为我在创建界面的那一刻无法访问。我还可以将我的实例传递到其他位置,然后初始化对象,然后运行我的接口。(但是,在使用之前检查一下是合适的)。仍然有意义。objobjnull

但现在我通过使用 lambda 表达式Runnable 实例的创建缩短为 burger-arrow 版本:

public class Foo {
    private final Object obj;
    public Foo() {
        Runnable run = () -> {
            obj.toString(); // error
        };
        obj = new Object();
        obj.toString(); // works again
    }
}

这就是我不能再跟随的地方。在这里,我再次收到警告。我知道编译器不会像通常的初始化那样处理lambda表达式,它不会“用长版本替换它”。但是,为什么这会影响我在创建对象时不在我的方法中运行代码部分的事实?我仍然能够在调用之前进行初始化。所以从技术上讲,在这里有可能不遇到。(虽然最好也在这里检查一下。但这个约定是另一个主题。run()Runnablerun()NullPointerExceptionnull

我犯了什么错误?lambda 的处理方式如此不同,以至于它影响了我的对象使用方式?

我感谢你所作的进一步解释。


答案 1

您可以通过以下方式绕过问题

        Runnable run = () -> {
            (this).obj.toString(); 
        };

这在 lambda 开发期间进行了讨论,基本上 lambda 主体在确定赋值分析期间被视为本地代码。

引用丹·史密斯,特沙尔,https://bugs.openjdk.java.net/browse/JDK-8024809

规则有两个例外: ...ii)从匿名类内部使用是可以的。在 lambda 表达式内部使用没有例外

坦率地说,我和其他一些人认为这个决定是错误的。lambda 只捕获 ,而不捕获 。这种情况应该与匿名类一样对待。对于许多合法的用例,当前的行为是有问题的。好吧,你总是可以使用上面的技巧绕过它 - 幸运的是,确定的分配分析不是太聪明,我们可以愚弄它。thisobj


答案 2

我无法重现 Eclipse 编译器的最终情况的错误。

但是,我能想象到的Oracle编译器的推理如下:在lambda内部,必须在声明时捕获的值。也就是说,在 lambda 主体内部声明它时,必须对其进行初始化。obj

但是,在这种情况下,Java应该捕获实例的值,而不是。然后,它可以通过(初始化的)对象引用进行访问并调用其方法。这就是 Eclipse 编译器编译代码段的方式。FooobjobjFoo

这在规范中有所提示,如下所示

方法引用表达式计算的时间比 lambda 表达式的计时更复杂 (§15.27.4)。当方法引用表达式在 :: 分隔符之前有一个表达式(而不是类型)时,将立即计算该子表达式。计算结果被存储,直到调用相应功能接口类型的方法;此时,结果将用作调用的目标引用。这意味着仅当程序遇到方法引用表达式时,才会计算 :: 分隔符前面的表达式,并且在对功能接口类型的后续调用时不会重新计算。

类似的事情发生在

Object obj = new Object(); // imagine some local variable
Runnable run = () -> {
    obj.toString(); 
};

Imagine是一个局部变量,当lambda表达式代码被执行时,被计算并产生一个引用。此引用存储在所创建实例的字段中。调用 时,实例将使用存储的引用值。objobjRunnablerun.run()

如果未初始化,则不会发生这种情况。例如obj

Object obj; // imagine some local variable
Runnable run = () -> {
    obj.toString(); // error
};

lambda 无法捕获 的值,因为它还没有值。它实际上等同于obj

final Object anonymous = obj; // won't work if obj isn't initialized
Runnable run = new AnonymousRunnable(anonymous);
...
class AnonymousRunnable implements Runnable {
    public AnonymousRunnable(Object val) {
        this.someHiddenRef = val;
    }
    private final Object someHiddenRef;
    public void run() {
        someHiddenRef.toString(); 
    }
}

这就是 Oracle 编译器当前对代码段的行为方式。

但是,Eclipse 编译器不会捕获 的值,而是捕获 (实例) 的值。它实际上等同于objthisFoo

final Foo anonymous = Foo.this; // you're in the Foo constructor so this is valid reference to a Foo instance
Runnable run = new AnonymousRunnable(anonymous);
...
class AnonymousRunnable implements Runnable {
    public AnonymousRunnable(Foo foo) {
        this.someHiddenRef = foo;
    }
    private final Foo someHiddenFoo;
    public void run() {
        someHiddenFoo.obj.toString(); 
    }
}

这很好,因为您假设实例在调用时已完全初始化。Foorun