为什么不应该让每个Scala实例变量成为一个懒惰的初始化变量?

时间:2013-06-26 18:59:01

标签: scala

除了增加的详细程度之外,还有其他强有力的理由说明为什么我们不应该声明每个实例变量应该被懒惰地初始化?

3 个答案:

答案 0 :(得分:20)

首先:如果懒惰的初始化出现问题(比如访问不存在的外部资源),你只会在第一次访问val时注意到它,而使用普通的val你会一旦构造了对象就立即注意到。你也可以在延迟的val中使用循环依赖,这将导致该类根本不起作用(一个可怕的NullPointerExceptions),但是你只会在第一次访问其中一个连接的lazy val时找到它。

如此懒惰的val会使程序的确定性降低,这总是一件坏事。

第二:懒惰的val涉及运行时开销。懒惰的val目前由一个使用惰性val的类中的私有位掩码(int)实现(每个lazy val一位,所以如果你有超过32个懒的val,那么将有两个位掩码等。)

为确保延迟val初始值设定项仅运行一次,在初始化字段时会对位掩码进行同步写入,并在每次访问字段时进行易失性读取。现在,在x86架构中,易失性读取相当便宜,但是易失性写入可能非常昂贵。

据我所知,目前正在努力在未来版本的scala中对此进行优化,但是与直接访问相比,检查字段是否已初始化总会有开销。例如,lazy val访问的额外代码可能会阻止方法内联。

当然对于一个非常小的类,位掩码的内存开销也可能是相关的。

但是,即使你没有任何性能问题,最好弄清楚val相互依赖的顺序,然后按顺序对它们进行排序并使用正常的val。

编辑:这是一个代码示例,说明了使用延迟val时可能获得的非确定性:

class Test {
  lazy val x:Int = y
  lazy val y:Int = x
}

您可以毫无问题地创建此类的实例,但只要您访问x或y,就会得到StackOverflow。这当然是一个人为的例子。在现实世界中,你有更长的和非明显的依赖周期。

这是一个使用的scala控制台会话:javap,它说明了lazy 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指令)。您还可以看到编译器生成的额外字段。

答案 1 :(得分:7)

首先,我们应该讨论lazy val s(Scala的“常量”),而不是懒惰的变量(我认为不存在)。

两个原因是可维护性和效率,特别是在类字段的上下文中:

效率:非延迟初始化的好处是可以控制它发生的位置。想象一下fork-join类型框架,在这个框架中,您可以在工作线程中生成许多对象,然后将它们交给中央处理。通过eager eval,初始化在工作线程上完成。使用延迟eval,这是在主线程上完成的,可能会造成瓶颈。

可维护性:如果您的所有值都被懒惰地初始化,并且您的程序爆炸,您将获得一个堆栈跟踪,该跟踪本地化在与实例初始化完全不同的上下文中,可能在另一个线程中

还有,几乎可以肯定的是,与语言实现相关的成本(我看到@Beryllium已经发布了一个例子),但我觉得不足以讨论它们。

答案 2 :(得分:0)

如果我读了你的代码并且你使用了懒惰我会花时间问你为什么你使用延迟初始化,这可能是除了性能损失之外最懒的延迟成本。

现在,您应该考虑延迟初始化(以及我将在此处包含的类似Streams):

循环依赖:一个变量依赖于另一个变量的初始化和/或反之亦然。 无限集:Streams允许您查找前1000个素数,而无需知道可能代表多少实数。

我确信还有其他几个 - 这些是我能看到的最重要的。

记住一个懒惰的val就像一个def只被评估一次并且知道你应该只在你真正需要的时候使用它,否则当他们问为什么它是懒的时候会混淆另一个开发人员?