【问题标题】:Is final ill-defined?最终定义不明确吗?
【发布时间】:2018-08-28 04:30:51
【问题描述】:

首先,一个谜题: 下面的代码打印什么?

public class RecursiveStatic {
    public static void main(String[] args) {
        System.out.println(scale(5));
    }

    private static final long X = scale(10);

    private static long scale(long value) {
        return X * value;
    }
}

答案:

0

以下剧透。


如果您按比例(长)打印X 并重新定义X = scale(10) + 3, 打印件将是X = 0,然后是X = 3。 这意味着X 暂时设置为0,之后又设置为3。 这违反了final

static修饰符与final修饰符结合,也用于定义常量。 final修饰符表示该字段的值不能改变

来源:https://docs.oracle.com/javase/tutorial/java/javaOO/classvars.html [强调添加]


我的问题: 这是一个错误吗? final 定义不明确吗?


这是我感兴趣的代码。 X 被分配了两个不同的值:03。 我认为这违反了final

public class RecursiveStatic {
    public static void main(String[] args) {
        System.out.println(scale(5));
    }

    private static final long X = scale(10) + 3;

    private static long scale(long value) {
        System.out.println("X = " + X);
        return X * value;
    }
}

此问题已被标记为可能与 Java static final field initialization order 重复。 我相信这个问题不是重复的,因为 另一个问题解决了初始化的顺序,而 我的问题解决了与final 标签相结合的循环初始化。 仅从另一个问题,我无法理解为什么我的问题中的代码不会出错。

通过查看 ernesto 获得的输出,这一点尤其明显: 当a 被标记为final 时,他得到以下输出:

a=5
a=5

这不涉及我问题的主要部分:final 变量如何更改其变量?

【问题讨论】:

  • 这种引用X成员的方式就像在超类构造函数完成之前引用子类成员,这是你的问题,而不是final的定义。
  • 来自 JLS:A blank final instance variable must be definitely assigned (§16.9) at the end of every constructor (§8.8) of the class in which it is declared; otherwise a compile-time error occurs.
  • @Ivan,这不是关于常量,而是关于实例变量。但是你可以添加章节吗?
  • 请注意:切勿在生产代码中执行任何此类操作。如果有人开始利用 JLS 中的漏洞,每个人都会感到非常困惑。
  • 仅供参考,您也可以在 C# 中创建完全相同的情况。 C# 承诺 constant 声明中的循环将在编译时被捕获,但没有对 readonly 声明做出这样的承诺,实际上您可能会遇到初始零值的情况由另一个字段初始化器观察到该字段。如果这样做时会感到疼痛,不要这样做。编译器不会救你的。

标签: java final class-variables static-initialization


【解决方案1】:

非常有趣的发现。要理解它,我们需要深入研究 Java 语言规范 (JLS)。

原因是final 只允许一个赋值。然而,默认值不是 assignment。事实上,每个这样的变量(类变量、实例变量、数组组件)从一开始就指向它的默认值,在赋值之前。然后第一个分配会更改引用。


类变量和默认值

看看下面的例子:

private static Object x;

public static void main(String[] args) {
    System.out.println(x); // Prints 'null'
}

我们没有明确地为x 赋值,尽管它指向null,它是默认值。比较至987654322 @:

变量的初始值

每个类变量、实例变量或数组组件在创建时使用默认值进行初始化(§15.9, @ 987654324@)

请注意,这只适用于那些类型的变量,就像我们的例子一样。它不适用于局部变量,请参见以下示例:

public static void main(String[] args) {
    Object x;
    System.out.println(x);
    // Compile-time error:
    // variable x might not have been initialized
}

来自同一个 JLS 段落:

局部变量§14.4§14.14)必须在使用前显式地赋值,通过初始化(§14.4)或赋值(§15.26),可以使用明确分配规则 (§16 (Definite Assignment)) 进行验证。


最终变量 h2>

现在我们来看看final,来自§4.12.4

最终 em>变量

变量可以声明为finalfinal 变量只能分配一次。如果一个 final 变量被赋值,这是一个编译时错误,除非它在赋值之前明确地未赋值 (§16 (Definite Assignment))。


说明

现在回到您的示例,稍作修改:

public static void main(String[] args) {
    System.out.println("After: " + X);
}

private static final long X = assign();

private static long assign() {
    // Access the value before first assignment
    System.out.println("Before: " + X);

    return X + 1;
}

输出

Before: 0
After: 1

回忆我们所学到的东西。在方法assign 中,变量X 尚未未分配 值。因此,它指向它的默认值,因为它是一个类变量,并且根据 JLS,这些变量总是立即指向它们的默认值(与局部变量相反)。在assign 方法之后,变量X 被赋值为1,由于final,我们不能再改变它了。因此,由于final,以下内容将不起作用:

private static long assign() {
    // Assign X
    X = 1;

    // Second assign after method will crash
    return X + 1;
}

在JLS h2>中的示例

感谢@Andrew,我找到了一个完全涵盖此场景的 JLS 段落,它也进行了演示。

但首先让我们来看看

private static final long X = X + 1;
// Compile-time error:
// self-reference in initializer

为什么不允许这样做,而从方法访问却是?看看§8.3.3,它讨论了如果字段尚未初始化,则何时限制对字段的访问。

它列出了一些与类变量相关的规则:

对于在类或接口C 中声明的类变量f 的简单名称引用,如果:

  • 引用出现在C 的类变量初始化器或C (§8.7) 的静态初始化器中;和

  • 引用要么出现在f 自己的声明符的初始化程序中,要么出现在f 的声明符左侧的某个点;和

  • 引用不在赋值表达式的左侧 (§15.26);和

  • 包含引用的最里面的类或接口是C

很简单,X = X + 1 被那些规则捕获,方法访问没有。他们甚至列出了这种情况并举了一个例子:

这种方式不检查方法的访问,所以:

class Z {
    static int peek() { return j; }
    static int i = peek();
    static int j = 1;
}
class Test {
    public static void main(String[] args) {
        System.out.println(Z.i);
    }
}

产生输出:

0

因为i的变量初始化器在j被它的变量初始化器初始化之前使用类方法peek来访问变量j的值,此时它仍然有它的默认值§4.12.5)。

【讨论】:

  • @Andrew 是的,类变量,谢谢。是的,如果没有限制此类访问的一些额外规则,它起作用:§8.3.3。查看为类变量(第一个条目)指定的四个点。 OPs 示例中的方法方法没有被这些规则捕获,因此我们可以从方法中访问X。我不介意那么多。这仅取决于 JLS 如何详细定义要工作的事物。我永远不会使用这样的代码,它只是利用 JLS 中的一些规则。
  • 问题是您可以从构造函数中调用实例方法,这可能是不允许的。另一方面,不允许在调用 super 之前分配 locals,这将是有用且安全的。去图吧。
  • @Andrew 你可能是这里唯一实际上提到forwards references的人(这也是JLS的一部分)。没有这个冗长的答案,这太简单了stackoverflow.com/a/49371279/1059372
  • “第一个赋值然后改变引用。”在这种情况下,它不是引用类型,而是原始类型。
  • 这个答案是对的,虽然有点长。 :-) 我认为 tl;dr 是 OP 引用了一个 tutorial 说“[a final] 字段不能改变”,而不是 JLS。虽然 Oracle 的教程非常好,但它们并没有涵盖所有边缘情况。对于 OP 的问题,我们需要转到 final 的实际 JLS 定义——并且该定义并没有提出(OP 理所当然地挑战)final 字段的值永远不会改变的声明。
【解决方案2】:

这里与final无关。

由于它处于实例或类级别,因此如果尚未分配任何内容,它会保留默认值。这就是您在未分配的情况下访问0 时看到它的原因。

如果您在没有完全分配的情况下访问X,它会保存long 的默认值0,因此结果。

【讨论】:

  • 这件事的棘手之处在于,如果你不赋值,它不会被赋值为默认值,但如果你用它来为自己赋值“最终”值,它会...
  • @AxelH 我明白你的意思了。但这就是它应该如何工作,否则世界就会崩溃;)。
【解决方案3】:

不是错误。

当第一次调用scale

private static final long X = scale(10);

它试图评估return X * valueX 尚未分配值,因此使用long 的默认值(即0)。

因此该行代码的计算结果为X * 10,即0 * 10,即0

【讨论】:

  • 我认为这不是 OP 所混淆的。令人困惑的是X = scale(10) + 3。因为X,当从方法中引用时,是0。但之后是3。所以OP认为X被分配了两个不同的值,这会与final冲突。
  • @Zabuza is not this explain with the "它试图评估return X * valueX尚未被赋值,因此采用long的默认值哪个是0." ?并不是说X 被分配了默认值,而是X 被默认值“替换”(请不要引用该术语;))。
【解决方案4】:

这根本不是错误,简单地说它不是前向引用的非法形式,仅此而已。

String x = y;
String y = "a"; // this will not compile 


String x = getIt(); // this will compile, but will be null
String y = "a";

public String getIt(){
    return y;
}

规范只允许这样做。

举个例子,这正是匹配的地方:

private static final long X = scale(10) + 3;

您正在对scale 进行前向引用,这在任何方面都不违法,但允许您获得X 的默认值。再一次,这是规范允许的(更准确地说,它不是禁止的),所以它工作得很好

【讨论】:

  • 好答案!我只是好奇为什么规范确实允许第二种情况编译。这是查看最终字段“不一致”状态的唯一方法吗?
  • @Andrew 这也困扰了我很长时间,我倾向于认为是 C++ 或 C 做到的(不知道这是否属实)
  • @Andrew:因为不这样做就是解决图灵不完备定理。
  • @Joshua:我认为您在这里混淆了许多不同的概念:(1)停止问题,(2)决策问题,(3)哥德尔不完备定理,以及(4)图灵- 完整的编程语言。编译器编写者不会尝试解决“这个变量在使用之前是否已明确分配?”的问题。完美,因为这个问题等同于解决停机问题,我们知道我们不能这样做。
  • @EricLippert:哈哈,哎呀。图灵不完备和停机问题在我脑海中占据了相同的位置。
【解决方案5】:

类级别成员可以在类定义中的代码中初始化。编译后的字节码无法内联初始化类成员。 (实例成员的处理方式类似,但这与提供的问题无关。)

当一个人写下如下内容时:

public class Demo1 {
    private static final long DemoLong1 = 1000;
}

生成的字节码类似于以下内容:

public class Demo2 {
    private static final long DemoLong2;

    static {
        DemoLong2 = 1000;
    }
}

初始化代码放置在静态初始化程序中,该初始化程序在类加载器首次加载类时运行。有了这些知识,您的原始样本将类似于以下内容:

public class RecursiveStatic {
    private static final long X;

    private static long scale(long value) {
        return X * value;
    }

    static {
        X = scale(10);
    }

    public static void main(String[] args) {
        System.out.println(scale(5));
    }
}
  1. JVM 加载 RecursiveStatic 作为 jar 的入口点。
  2. 类加载器在加载类定义时运行静态初始化程序。
  3. 初始化程序调用函数scale(10)来分配static final字段X
  4. scale(long) 函数在类部分初始化时运行,读取 X 的未初始化值,默认值为 long 或 0。
  5. 0 * 10 的值被分配给X 并且类加载器完成。
  6. JVM 运行调用 scale(5) 的公共静态 void main 方法,该方法将 5 乘以现在初始化的 X 值 0 返回 0。

静态最终字段X 只分配一次,保留final 关键字所持有的保证。对于后续在赋值中加3的查询,上面的步骤5变成了0 * 10 + 3的求值,即3的值,main方法将打印3 * 5的结果,即15的值。

【讨论】:

    【解决方案6】:

    读取对象的未初始化字段应该会导致编译错误。不幸的是,Java 没有。

    我认为这种情况的根本原因是“隐藏”在对象如何实例化和构造的定义中,尽管我不知道标准的细节。

    从某种意义上说,final 的定义不明确,因为由于这个问题,它甚至没有完成其声明的目的。然而,如果你所有的类都写得正确,你就不会有这个问题。这意味着始终在所有构造函数中设置所有字段,并且在不调用其构造函数之一的情况下不会创建任何对象。在您必须使用序列化库之前,这似乎很自然。

    【讨论】:

      猜你喜欢
      • 2014-12-12
      • 1970-01-01
      • 1970-01-01
      • 2022-01-24
      • 2011-06-10
      • 1970-01-01
      • 2014-04-07
      • 2011-11-07
      • 2011-08-26
      相关资源
      最近更新 更多