【Scala谜题】继承

笔记来源:
Scala谜题

多级继承

Scala 支持面向对象的编程概念,继承是它的一个很重要的特征。继承通常对父类和特质中定义的缺省值的重载很有用。当增加多级继承时事情变得更加有趣,例如下面这段程序。

trait A {
  val foo: Int
  val bar = 10
  println("In A: foo: " + foo + ", bar: " + bar)
}
class B extends A {
  val foo: Int = 25
  println("In B: foo: " + foo + ", bar: " + bar)
}
class C extends B {
  override val bar = 99
  println("In C: foo: " + foo + ", bar: " + bar)
}
new C()

它的打印结果我一开始也没想到:

In A:foo:0,bar:0
In B:foo:25,bar:0
In C:foo:25,bar:99

首先我们要知道 val 的初始化和重载规则:

  1. 超类会在子类之前初始化;
  2. 按照声明的顺序对成员初始化;
  3. 当一个 val 被重载时,只能初始化一次;
  4. 与抽象 val 类似,重载的 val 在超类构造期间会有一个缺省的初始化。

因此,根据以上规则,在最开始的那段程序中,虽然表面上看在特质 A 中给 bar 分配了一个初始值,实际上却不是这样,因为类 C 中重载了 bar。这意味着特质 A 构造时,给bar 分配了一个缺省初始值
0,而不是原有的值 10。

Scala 从Java 中继承了初始化顺序规则。Java 确保首先初始化超类,这样就可以从子类构造器中安全使用超类字段,确保正确地初始化字段。特质会被编译成接口和具体的(非抽象的)类,所以也可应用同样的规则。

Scala 给记录赋的缺省初始值为:

  • Byte、Short 和Int 类型val 的初始值是0
  • Int、Long、Float 和Double 类型val 的初始值分别是0L、0.0f 和0.0d
  • Char 类型val 的初始值是’0′
  • Boolean 类型val 的初始值是false
  • Unit 的初始值是()
  • 所有其他类型的初始值是 null

那如果我们想要展示的是这样的结果:


In A:foo:0,bar:99
In B:foo:25,bar:99
In C:foo:25,bar:99

我们应该怎么做呢:

用定义

一种方法是将 bar 声明为 def,而不是 val。之所以这个方法能解决问题,是因为 def 这个方法体不属于主构造器,因此不参与类初始化。此外,因为类 C 中重载了 bar,多态会特别选择使用这个重载的定义。因此,3 个 println 语句中的 bar 都会调用类 C 中的重载定义。

trait A {
  val foo: Int
  def bar: Int = 10
  println("In A: foo: " + foo + ", bar: " + bar)
}
class B extends A {
  val foo: Int = 25
  println("In B: foo: " + foo + ", bar: " + bar)
}
class C extends B {
  override def bar: Int = 99
  println("In C: foo: " + foo + ", bar: " + bar)
}

这种方法的一个缺点是每次调用都要评估。Scala 也遵从统一访问原则,所以在超类中定义一个参数方法不会阻止在子类中将它重载为一个 val,这会导致令人迷惑的行为再次出现,从而破坏原有的架构规划。

lazy val

另外一种避免这种意外的方法是将 bar 声明为 lazy vallazy val 在初次访问时初始化。而常规的 val,又叫静态变量,是在定义时初始化的。lazy val 使用编译器生成的方式初始化,这里将调用特质 Cbar 的重载版本。注意,lazy val 的特点是将高成本的初始化过程尽可能推迟到最后时刻(有时可能永远也不进行初始化)。

trait A {
  val foo: Int
  lazy val bar = 10
  println("In A: foo: " + foo + ", bar: " + bar)
}
class B extends A {
  val foo: Int = 25
  println("In B: foo: " + foo + ", bar: " + bar)
}
class C extends B {
  override lazy val bar = 99
  println("In C: foo: " + foo + ", bar: " + bar)
}

不过,要注意的是,lazy val 也有一些缺点:

  1. 由于在底层发生同步,这会引起轻微的性能成本;
  2. 不能声明抽象 lazy val
  3. 使用 lazy val 容易产生循环引用,从而导致首次访问时发生栈溢出错误,甚至可能发生死锁;
  4. 如果在对象间做了声明而 lazy val 间的循环依赖却不存时,就可能会发生死锁,这种情况也许非常微妙,不易觉察。

预初始化字段

使用预初始化字段(也就是大家所知道的早期初始化器)也可以达到相同的效果:

trait A {
  val foo: Int
  val bar = 10
  println("In A: foo: " + foo + ", bar: " + bar)
}
class B extends A {
  val foo: Int = 25
  println("In B: foo: " + foo + ", bar: " + bar)
}
class C extends {
  override val bar = 99
} with B {
  println("In C: foo: " + foo + ", bar: " + bar)
}

这段程序与原来的程序的唯一差别,就是 bar 在类 C 的早期字段定义从句中初始化。早期字段定义从句紧跟着 extends 关键字后的大括号,它是子类的一部分,在超类构造器之前运行。这样就可以确保 bar 在特质 A 被构造之前即被初始化。

总结

用什么方法解决潜在的初始化顺序问题,是因不同的用例而有所不同的:

  • 如果每次访问评估表达式的成本不是太高,也许会用定义的方法。
  • 或者只要能避免循环依赖,就可以用 lazy val 的方法,这对用户的类来说也许是最简单的解决方案。
  • 或者,如果用户很清楚他们应该使用早期字段定义,那么简单地使用原来的抽象 val 也是一个不错的选择。
    原文作者:gcusky
    原文地址: https://segmentfault.com/a/1190000015216089
    本文转自网络文章,转载此文章仅为分享知识,如有侵权,请联系博主进行删除。
点赞