笔记来源: 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
的初始化和重载规则:
- 超类会在子类之前初始化;
- 按照声明的顺序对成员初始化;
- 当一个
val
被重载时,只能初始化一次; - 与抽象
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 val
。lazy val
在初次访问时初始化。而常规的 val
,又叫静态变量,是在定义时初始化的。lazy val
使用编译器生成的方式初始化,这里将调用特质 C
中 bar
的重载版本。注意,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
也有一些缺点:
- 由于在底层发生同步,这会引起轻微的性能成本;
- 不能声明抽象
lazy val
; - 使用
lazy val
容易产生循环引用,从而导致首次访问时发生栈溢出错误,甚至可能发生死锁; - 如果在对象间做了声明而
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
也是一个不错的选择。