除了增加的冗长之外,还有其他强有力的理由为什么不应该声明每个实例变量都应该被延迟初始化?
3 回答
首先:如果惰性 val 的初始化出现问题(例如访问不存在的外部资源),您只会在第一次访问 val 时注意到它,而使用正常的 val 您会很快注意到在构建对象时。您还可以在惰性 val 中存在循环依赖,这将导致类根本无法工作(可怕的 NullPointerExceptions 之一),但只有在第一次访问连接的惰性 val 时才会发现。
所以懒惰的 val 使程序的确定性降低,这总是一件坏事。
第二:惰性验证涉及运行时开销。惰性 val 当前由使用惰性 val 的类中的私有位掩码 (int) 实现(每个惰性 val 一个位,因此如果您有超过 32 个惰性 val,则将有两个位掩码等)
为确保惰性 val 初始化程序仅运行一次,在初始化字段时对位掩码进行同步写入,并在每次访问字段时进行易失性读取。现在,在 x86 架构上,易失性读取非常便宜,但易失性写入可能非常昂贵。
据我所知,在未来版本的 scala 中正在努力优化这一点,但与直接 val 访问相比,检查字段是否已初始化总是会有开销。例如,惰性 val 访问的额外代码可能会阻止方法被内联。
当然,对于一个非常小的类,位掩码的内存开销也可能是相关的。
但即使您没有任何性能问题,最好弄清楚 val 相互依赖的顺序,然后按该顺序对它们进行排序并使用正常的 val。
编辑:这是一个代码示例,说明了如果您使用惰性 val 可能会得到的不确定性:
class Test {
lazy val x:Int = y
lazy val y:Int = x
}
您可以毫无问题地创建此类的实例,但是一旦您访问 x 或 y,您将获得 StackOverflow。这当然是一个人为的例子。在现实世界中,您有更长且不明显的依赖周期。
这是一个使用 :javap 的 scala 控制台会话,它说明了惰性 val 的运行时开销。首先是一个正常的 val:
scala> class Test { val x = 0 }
defined class Test
scala> :javap -c Test
Compiled from "<console>"
public class Test extends java.lang.Object implements scala.ScalaObject{
public int x();
Code:
0: aload_0
1: getfield #11; //Field x:I
4: ireturn
public Test();
Code:
0: aload_0
1: invokespecial #17; //Method java/lang/Object."<init>":()V
4: aload_0
5: iconst_0
6: putfield #11; //Field x:I
9: return
}
现在是懒惰的 val:
scala> :javap -c Test
Compiled from "<console>"
public class Test extends java.lang.Object implements scala.ScalaObject{
public volatile int bitmap$0;
public int x();
Code:
0: aload_0
1: getfield #12; //Field bitmap$0:I
4: iconst_1
5: iand
6: iconst_0
7: if_icmpne 45
10: aload_0
11: dup
12: astore_1
13: monitorenter
14: aload_0
15: getfield #12; //Field bitmap$0:I
18: iconst_1
19: iand
20: iconst_0
21: if_icmpne 39
24: aload_0
25: iconst_0
26: putfield #14; //Field x:I
29: aload_0
30: aload_0
31: getfield #12; //Field bitmap$0:I
34: iconst_1
35: ior
36: putfield #12; //Field bitmap$0:I
39: getstatic #20; //Field scala/runtime/BoxedUnit.UNIT:Lscala/runtime/BoxedUnit;
42: pop
43: aload_1
44: monitorexit
45: aload_0
46: getfield #14; //Field x:I
49: ireturn
50: aload_1
51: monitorexit
52: athrow
Exception table:
from to target type
14 45 50 any
public Test();
Code:
0: aload_0
1: invokespecial #26; //Method java/lang/Object."<init>":()V
4: return
}
如您所见,普通的 val 访问器非常短,肯定会被内联,而惰性 val 访问器非常复杂,并且(最重要的是用于并发)涉及一个同步块(monitorenter/monitorexit 指令)。您还可以看到编译器生成的额外字段。
首先,我们应该谈论lazy val
s(Scala 的“常量”),而不是惰性变量(我认为不存在)。
两个原因是可维护性和效率,尤其是在类字段的上下文中:
效率:非惰性初始化的好处是您可以控制它发生的位置。想象一个 fork-join 类型的框架,您在工作线程中生成许多对象,然后将它们交给中央处理。使用 Eager eval,初始化是在工作线程上完成的。使用惰性评估,这是在主线程上完成的,可能会造成瓶颈。
可维护性:如果您的所有值都被延迟初始化,并且您的程序崩溃了,您将获得一个堆栈跟踪,该堆栈跟踪位于与您的实例初始化完全不同的上下文中,可能在另一个线程中。
几乎可以肯定,还有与语言实现相关的成本(我看到@Beryllium 发布了一个示例),但我觉得没有足够的能力来讨论它们。
如果我阅读了您的代码并且您使用了惰性,我会花时间询问您为什么使用惰性初始化,这可能是除了性能损失之外最昂贵的惰性成本。
现在您应该考虑延迟初始化(以及我将在此处包含的类似 Streams)是:
循环依赖:一个变量依赖于另一个被初始化的变量和/或反之亦然。无限集:流允许您找到前 1000 个素数,而无需知道可能代表多少实数。
我敢肯定还有其他几个——这些是我能看到的大的。
请记住,惰性 val 就像只计算一次的 def,并且知道您应该只在实际需要时才使用它,否则当其他开发人员问为什么它是惰性的时,它会混淆它?