何时在Scala traits中使用val或def?

104

我正在查看effective scala幻灯片,它在第10页提到永远不要在trait中使用val来定义抽象成员,而是应该使用def。幻灯片没有详细说明为什么在trait中使用抽象的val是一种反模式。如果有人能够解释在trait中使用valdef来定义抽象方法的最佳实践,我将不胜感激。

4个回答

138
一个成员函数可以由 defvallazy val 或者 object 来实现,其中 def 是最抽象的形式。由于 traits 通常是抽象接口,如果你需要一个 val,你就是在说如何完成实现。如果你要求一个 val,那么实现类将不能使用 def
只有在需要稳定标识符(例如路径相关类型)时才需要 val,而这通常是不必要的。
比较:
trait Foo { def bar: Int }

object F1 extends Foo { def bar = util.Random.nextInt(33) } // ok

class F2(val bar: Int) extends Foo // ok

object F3 extends Foo {
  lazy val bar = { // ok
    Thread.sleep(5000)  // really heavy number crunching
    42
  }
}

如果你有

trait Foo { val bar: Int }

你将无法定义F1F3


好的,为了让你困惑并回答@om-nom-nom——使用抽象的val可能会导致初始化问题:

trait Foo { 
  val bar: Int 
  val schoko = bar + bar
}

object Fail extends Foo {
  val bar = 33
}

Fail.schoko  // zero!!

这是一个很丑陋的问题,我个人认为在未来的Scala版本中应该通过在编译器中修复它来解决,但是,是的,目前这也是不应该使用抽象val的原因之一。

编辑(2016年1月):您可以使用lazy val实现覆盖抽象val声明,从而防止初始化失败。


8
关于棘手的初始化顺序和令人惊讶的空值,这些词语。 - om-nom-nom
是的...我甚至不会去那里。确实,这些也是反对val的论点,但我认为基本动机应该是隐藏实现。 - 0__
2
这可能在最近的Scala版本中有所改变(截至本评论为2.11.4),但您可以使用lazy val覆盖val。您声称如果barval,则无法创建F3是不正确的。话虽如此,在特质中的抽象成员应始终是def - mplis
2
如果你将 val bar: Int 改为 def bar: IntFail.schoko 仍然是零。 - Jasper-M
发生在我身上,很高兴在这篇文章中找到了这个编辑,抽象整数值确实被零化,就像这里描述的一样。 - Deliganli
显示剩余5条评论

8

我更喜欢在特质中不使用val,因为val声明的初始化顺序不够清晰和直观。你可能会将一个特质添加到已经工作的层次结构中,这将破坏之前所有工作的内容,请参考我的主题:为什么在非最终类中使用纯val

你应该牢记有关使用此val声明的所有事项,这最终会导致错误。


使用更复杂的示例进行更新

但是有时您无法避免使用val。 正如 @0__ 提到的那样,有时您需要一个稳定的标识符,而def则不是。

我将提供一个示例以展示他所说的内容:

trait Holder {
  type Inner
  val init : Inner
}
class Access(val holder : Holder) {
  val access : holder.Inner =
    holder.init
}
trait Access2 {
  def holder : Holder
  def access : holder.Inner =
    holder.init
}

这段代码会产生错误:
 StableIdentifier.scala:14: error: stable identifier required, but Access2.this.holder found.
    def access : holder.Inner =

如果你花一分钟思考,你就会理解编译器抱怨的原因。在“Access2.access”案例中,它无法通过任何方式推导出返回类型。 “def holder”意味着它可以以广泛的方式实现。它可以为每个调用返回不同的持有者,并且这些持有者将包含不同的“Inner”类型。但是Java虚拟机希望返回相同的类型。

3
初始化的顺序不应该有影响,但是在运行时却会出现令人惊讶的空指针异常,这是反模式。 - Coder Guy
Scala具有声明性语法,可以隐藏其背后的命令式本质。有时候这种命令式特性会产生反直觉的效果。 - ayvango

0

我同意其他答案关于避免使用抽象val的原因是它为实现提供了更多选项。

有些情况下你可能需要它们:

  • 对于路径相关类型(如@0__所提到的)。
  • 当实现可能很昂贵并且在具体的def中使用时。
  • (还有其他情况吗?如果有,请评论,我会添加进去)。

更重要的是要知道何时可以安全地用val覆盖某个东西,并拥有一个不覆盖任何东西的lazy val


规则 1:永远不要使用非惰性 val 覆盖 valdef,除非它是构造函数的参数:

trait TraitWithVal {
  // It makes no difference if this is concrete or abstract.
  val a: String
  val b: String = a
}

class OverrideValWithVal extends TraitWithVal {
  // Bad: b will be null.
  override val a: String = "a"
}

class OverrideValWithLazyVal extends TraitWithVal {
  // Ok: b will be "a".
  override lazy val a: String = "a"
}

// Ok: b will be "a".
class OverrideValWithConstructorVal(override val a: String = "a") extends TraitWithVal

//class OverrideValWithDef extends TraitWithVal {
//  // Compilation error: method a needs to be a stable, immutable value.
//  override def a: String = "a"
//}

println((new OverrideValWithVal).b) // null
println((new OverrideValWithLazyVal).b) // a
println((new OverrideValWithConstructorVal).b) // a

同样的规则也适用于 def

trait TraitWithDef {
  // It makes no difference if this is concrete or abstract.
  def a: String
  val b: String = a
}

class OverrideDefWithVal extends TraitWithDef {
  // Bad: b will be null.
  override val a: String = "a"
}

class OverrideDefWithLazyVal extends TraitWithDef {
  // Ok: b will be "a".
  override lazy val a: String = "a"
}

// Ok: b will be "a".
class OverrideDefWithConstructorVal(override val a: String = "a") extends TraitWithDef

class OverrideDefWithDef extends TraitWithDef {
  // Ok: b will be "a".
  override def a: String = "a"
}

println((new OverrideDefWithVal).b) // null
println((new OverrideDefWithLazyVal).b) // a
println((new OverrideDefWithConstructorVal).b) // a
println((new OverrideDefWithDef).b) // a

你可能会想知道,在初始化期间没有使用某个 val 变量的情况下,是否可以将其替换为另一个 val 变量。但是至少有一种边界情况会破坏这一点:

trait TraitWithValAndLazyVal {
  val a: String = "A"
  def b: String = a
}

class OverrideLazyValWithVal extends TraitWithValAndLazyVal {
  // Bad: This on its own is ok but not if it is indirectly referenced during initialisation and overridden.
  override val a = "a"
  val c = b
}

class OverrideValWithVal extends OverrideLazyValWithVal {
  override val a = "a"
}

println((new OverrideValWithVal).a) // a
println((new OverrideValWithVal).b) // a
println((new OverrideValWithVal).c) // null

考虑到我们已经将此规则应用于覆盖def,在我看来,这使得使用val更加可接受。

如果您使用linter强制执行override关键字,并确保您的代码从未有任何override val定义,则很好。

您可能可以允许final override val,但可能存在其他我没有考虑到的边缘情况。


规则2:永远不要使用一个没有覆盖另一个lazy valdeflazy val

据我所知,也没有什么好理由去使用一个lazy val,它并没有覆盖其他东西。所有我能想到需要使用它的例子,都是因为它违反了规则1,并暴露了我之前描述的边缘情况。

例如:

trait NormalLookingTrait {
  def a: String
  val b: String = a
}

trait TraitWithAbstractVal extends NormalLookingTrait {
  val c: String
}

class OverrideValWithVal extends TraitWithAbstractVal {
  override def a: String = c
  override val c = "a"
}

println((new OverrideValWithVal).a) // a
println((new OverrideValWithVal).b) // null
println((new OverrideValWithVal).c) // a

所以我们将b定义为lazy val

trait SuspiciousLookingTrait2 {
  def a: String
  lazy val b: String = a
}

trait TraitWithAbstractVal2 extends SuspiciousLookingTrait2 {
  val c: String
}

class OverrideValWithVal2 extends TraitWithAbstractVal2 {
  override def a: String = c
  override val c = "a"
}

println((new OverrideValWithVal2).a) // a
println((new OverrideValWithVal2).b) // a
println((new OverrideValWithVal2).c) // a

看起来还不错,除非我们再深入一步:

trait SuspiciousLookingTrait2 {
  def a: String
  lazy val b: String = a
}

trait TraitWithAbstractVal2 extends SuspiciousLookingTrait2 {
  val c: String
}

class OverrideValWithVal2 extends TraitWithAbstractVal2 {
  override def a: String = c
  override val c = "a"
  val d = b
}

class OverrideValWithVal3 extends OverrideValWithVal2 {
  override val c = "a"
}

println((new OverrideValWithVal3).a) // a
println((new OverrideValWithVal3).b) // null
println((new OverrideValWithVal3).c) // a
println((new OverrideValWithVal3).d) // null

现在我明白人们说的仅在绝对必要时使用lazy,而不是用于延迟初始化。

如果特质/类是final,那么打破这个规则可能是安全的,但即使如此也有点可疑。


我刚意识到规则1也适用于具有具体val的类。这意味着,如果一个类在其初始化中使用了另一个val,那么被引用的val必须是final的,否则就会在扩展时冒着出现null的风险。 - steinybot

-4

总是使用 def 似乎有点笨拙,因为像这样的东西不起作用:

trait Entity { def id:Int}

object Table { 
  def create(e:Entity) = {e.id = 1 }  
}

您将会收到以下错误提示:
error: value id_= is not a member of Entity

2
如果你使用val而不是def,那么会出现错误(错误:重新分配给val),这是完全合理的。 - volia17
不过如果你使用 var 的话就不一样了。关键是,如果它们是字段,那么应该将它们指定为字段。我认为把所有东西都定义为 def 是短视的。 - Dimitry
@Dimitry,当然,使用var会破坏封装性。但是,使用def(或val)优于全局变量。我认为你要找的是像case class ConcreteEntity(override val id: Int) extends Entity这样的东西,这样你就可以从def create(e: Entity) = ConcreteEntity(1)创建它。这比打破封装并允许任何类更改实体更安全。 - Jono

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接