为什么父列表中的第一个基类必须是非特征类?

问题描述 投票:0回答:2

Scala spec,据说在类模板sc extends mt1, mt2, ..., mtn

每个性状参考mti必须表示特征。相比之下,超类构造函数sc通常指的是不是特征的类。可以编写以特征参考开头的父母列表,例如mt1与......与mtn。在这种情况下,父项列表被隐式扩展为包括mt1的超类型作为第一父类型。新的超类型必须至少有一个不带参数的构造函数。在下文中,我们将始终假设已执行此隐式扩展,以便模板的第一个父类是常规超类构造函数,而不是特征引用。

如果我理解正确,我认为这意味着:

trait Base1 {}
trait Base2 {}
class Sub extends Base1 with Base2 {}

将隐含地扩展到:

trait Base1 {}
trait Base2 {}
class Sub extends Object with Base1 with Base2 {}

我的问题是:

  1. 我的理解是否正确?
  2. 此要求(父列表中的第一个子类必须是非特征类)和隐式扩展是否仅适用于类模板(例如class Sub extends Mt1, Mt2)或特征模板(例如trait Sub extends Mt1, Mt2)?
  3. 为什么这个要求和隐式扩展是必要的?
scala inheritance traits
2个回答
1
投票

免责声明:我不是,也不是“斯卡拉设计委员会”的成员或类似的东西,所以关于“为什么?”的答案问题主要是猜测,但我认为是有用的。

免责声明#2:我已经写了几个小时的这篇文章,并且几次拍摄,所以它可能不是很一致

免责声明#3(对未来的读者来说是一种可耻的自我推销):如果你觉得这个很长的答案很有用,你也可以看看 my another long answer 对Lifu Huang在类似主题上提出的另一个问题。

简短的答案

这是一个复杂的事情,除非你已经知道答案是什么,否则我不认为有一个很好的简短回答。虽然我的答案很长,但这是我最好的答案:

为什么父列表中的第一个基类必须是非特征类?

因为必须只有一个非特质基类,如果它始终是第一个,它会使事情变得更容易

  1. 我的理解是否正确?

是的,您的隐含示例是将要发生的事情。但是我不确定它是否完全理解了这个主题。

  1. 此要求(父列表中的第一个子类必须是非特征类)和隐式扩展是否仅适用于类模板(例如class Sub extends Mt1, Mt2)或特征模板(例如trait Sub extends Mt1, Mt2)?

不,对于特征也会发生隐式扩展。实际上你怎么能期望Mt1有自己的“超类型”被提升到扩展它的类?

实际上这里有两个恕我直言的非显而易见的例子证明这是真的:

示例#1

trait TAny extends Any

trait TNo 

// works
class CGood(val value: Int) extends AnyVal with TAny 
// fails 
// illegal inheritance; superclass AnyVal is not a subclass of the superclass Object
class CBad(val value: Int) extends AnyVal with TNo 

该示例失败,因为规范说

可以省略extends子句extends scsc with mt1mt1 with …… with mtnmtn,在这种情况下假设为extends scala.AnyRef

所以TNo实际上扩展了与AnyRef不相容的AnyVal

例#2

class CFirst
class CSecond extends CFirst

// did you know that traits can extend classes as well?
trait TFirst extends CFirst
trait TSecond extends CSecond

// works
class ChildGood extends TSecond with TFirst
// fails 
// illegal inheritance; superclass CFirst is not a subclass of the superclass CSecond of the mixin trait TSecond
class ChildBad extends TFirst with TSecond

再次ChildBad失败,因为TSecond需要CSecondTFirst只提供CFirst作为基类。

  1. 为什么这个要求和隐式扩展是必要的?

主要有三个原因:

  1. 与主目标平台(JVM)的兼容性
  2. 特征有"mixin"语义:你有一个类,你混合了额外的行为
  3. 其余规范的完整性,一致性和简单性(例如linearization rules)。这可能会重述如下:每个类必须声明0或1个基本的非特征类,并且在编译之后,目标平台强制执行恰好有1个非特征基类。因此,如果你只假设总有一个基类,它会使规范的其余部分更容易。以这种方式,您必须只编写一次这种隐式扩展规则,而不是每次行为依赖于基类时。

Scala spec目标/意图

我相信,当读一个规范时,有两组不同的问题:

  1. 究竟是什么写的?规范是什么意思?
  2. 为什么这样写?意图是什么?

实际上我认为在很多情况下#2比#1更重要,但不幸的是,规范很少明确包含对该领域的见解。无论如何,我将从我对#2的猜测开始:Scala中类系统的意图/目标/限制是什么?主要的高级目标是创建一个比Java或.Net(非常相似)更丰富的类型系统,但可以是:

  1. 编译回这些目标平台中的有效代码
  2. 允许Scala代码与目标平台中的“本机”代码之间进行合理的双向交互

旁注:.Net的支持在几年前就被取消了,但多年来它一直是目标平台之一,这影响了设计。

单基类

简短摘要:本节描述了为什么Scala设计师有强烈动机在语言中使用“完全一个基类”规则的一些原因。

OO设计和特别是继承的一个主要问题是AFAIK的问题是:“好的和有用的”做法和“坏的”做法之间的界限究竟在哪里?“开了。这意味着每种语言都必须找出自己的权衡,使不可能出错的事情和使有用的事情成为可能(并且容易)。许多人认为,在C ++中,这显然是Java和.Net的主要灵感,这种权衡过度转移到“允许所有内容,即使它是潜在有害的”区域。它使许多新语言的设计者寻求更多限制性的权衡。特别是JVM和.Net平台都强制执行所有类型被分为“值类型”(又名原始类型),“类”和“接口”的规则,除了根类(java.lang.Object / System.Object)之外,每个类都只有一个“基类”和零个或多个“基本接口”。这个决定是对multiple inheritance的许多问题的反应,包括臭名昭着的“钻石问题”,但实际上还有许多其他问题。

Sidenote(关于内存布局):多重继承的另一个主要问题是内存中的对象布局。考虑下面由Achilles and the tortoise启发的荒谬(当前Scala中不可能)的例子:

trait Achilles {
  def getAchillesPos: Int
  def stepAchilles(): Unit
}

class AchillesImpl(var achillesPos: Int) extends Achilles {
  def getAchillesPos: Int = achillesPos
  def stepAchilles(): Unit = {
    achillesPos += 2
  }
}

class TortoiseImpl(var tortoisePos: Int) {      
  def getTortoisePos: Int = tortoisePos
  def stepTortoise(): Unit = {
    tortoisePos += 1
  }
}

class AchillesAndTortoise(handicap: Int) extends AchillesImpl(0) with TortoiseImpl(handicap) {
  def catchTortoise(): Int = {
    var time = 0
    while (getAchillesPos < getTortoisePos) {
      time += 1
      stepAchilles()
      stepTortoise()
    }
    time 
  }
}

这里棘手的部分是如何在对象的内存中实际放置achillesPostortoisePos字段。问题是您可能希望只有一个内存中所有方法的编译副本,并且您希望代码高效。这意味着getAchillesPosstepAchilles应该知道achillesPos关于this指针的一些固定偏移量。类似地,getTortoisePosstepTortoise应该知道tortoisePos关于this指针的一些固定偏移量。你实现这一目标所需的所有选择看起来并不好看。例如:

  1. 你可能会认为achillesPos总是第一名,tortoisePos总是第二名。但这意味着在TortoiseImpl的情况下,tortoisePos也应该是第二个字段,但没有任何东西可以填充第一个字段,所以你浪费了一些记忆。此外,如果AchillesImplTortoiseImpl都来自预编译库,您应该有一些方法来移动对它们中的字段的访问。
  2. 当你调用this时,你可能会尝试“动态”修复TortoiseImpl指针(AFAIK这是C ++真正起作用的方式)。当TortoiseImpl是一个抽象类,通过trait Achilles知道class AchillesImpl(但不是特定的extends)并尝试从那里通过this回调一些方法或通过this到一些以Achilles为参数的方法时,这变得特别有趣,所以this必须“修复”。请注意,这与“钻石问题”不同,因为所有字段和实现只有一个副本。
  3. 您可能同意拥有为每个特定类编译的方法的唯一副本,这些方法知道特定的布局。这对内存使用和性能都有害,因为它会破坏CPU缓存并迫使JIT对每个缓存进行独立优化。
  4. 您可能会说除了getter和setter之外没有任何方法可以直接访问字段,而应该使用getter和setter。或者将所有字段存储在某种实际上相同的字典中。这可能对性能有害(但这与Scala对mixin-traits的作用最接近)。

在实际的Scala中,这个问题不存在,因为trait无法真正声明任何字段。当您在特征中声明valvar时,您实际上声明了一个getter(和一个setter)方法,这些方法将由扩展特征的特定类实现,并且每个类都可以完全控制字段的布局。实际上,就性能而言,这很可能正常,因为JVM(JIT)可以在许多真实场景​​中内联这样的虚拟调用。

旁注结束

另一个要点是与目标平台的互操作性。即使Scala以某种方式支持真正的多重继承,所以你可以拥有一个继承自String with Date的类型,并且可以传递给期望String和期望Date的两种方法,从Java的角度来看这会是什么样子?此外,如果目标平台强制执行规则,即每个类必须是同一根类(Object)的(间接)子类型,则无法在更高级别的语言中使用它。

特质和混合

许多人认为用Java和.Net制作的“一类和多接口”权衡过于严格。例如,它使得很难在不同类之间共享某些接口方法的公共默认实现。实际上,Java和.Net设计人员似乎得出了相同的结论并推出了针对此类问题的修复程序:.net中的Extension methods,然后是Java中的Default methods。 Scala设计师添加了一个名为Mixins的功能,在许多实际情况下,这个功能都很出色。然而,与许多具有类似功能的其他动态语言不同,Scala仍然必须满足“完全一个基类”规则和目标平台的其他限制。

重要的是要注意,在实践中使用mixins的一些重要场景是实现DecoratorAdapter模式的变体,这两种模式都依赖于这样一个事实,即你可以将你的基类型限制为比AnyAnyRef更具体的东西。这种用法的主要例子是scala.collection包。

Scala语法

所以现在你有以下目标/限制:

  1. 每个类只有一个基类
  2. 能够从mixin向类添加逻辑
  3. 支持基本类型受限的mixins
  4. 从Scala看到的目标平台(Java)中的类被映射到Scala类(因为它们还可以映射到什么?)并且它们是预编译的,我们不想搞乱它们的实现
  5. 其他好的品质,如简单,类型安全,决定论等。

如果您希望在您的语言中使用某种多重继承支持,则需要开发冲突解决规则:当几种基类型提供适合您类中相同“槽”的逻辑时会发生什么。在禁止特征字段后,我们留下以下“插槽”:

  1. 基于目标平台的基类
  2. 构造函数
  3. 具有相同名称和签名的方法

可能的冲突解决策略是:

  1. 禁止(编译失败)
  2. 决定哪一个获胜并擦除其他人
  3. 以某种方式链接他们
  4. 以某种方式保留所有重命名。这在JVM中实际上是不可能的。例如在.Net中查看Explicit Interface Implementation

从某种意义上说,Scala使用所有可用的(即前3个)策略,但高级目标是:让我们尽可能多地保留逻辑。

本讨论最重要的部分是构造函数和方法的冲突解决。

我们希望不同时段的规则相同,否则不清楚如何实现安全性(如果特征AB都覆盖方法foobarfoobar的分辨率规则不同,AB的不变量可能很容易被打破)。 Scala的方法基于class linearization。简而言之,这是以某种预测方式将基类的层次结构“扁平化”为简单线性结构的方式,这种方式基于with链中的lefter类型 - 更多“基数”(继承中更高)的概念它是。执行此操作后,方法的冲突解决规则变得简单:您通过super调用查看基本类型和链行为的列表;如果没有调用super,你就会停止链接。这产生了人们可以推理的非常可预测的语义。

现在假设您允许非特质类不是第一个。考虑以下示例:

class CBase {
  def getValue = 2
}
trait TFirst extends CBase {
  override def getValue = super.getValue + 1
}
trait TSecond extends CFirst {
  override def getValue = super.getValue * 2
}
class CThird extends CBase with TSecond {
  override def getValue = 100 - super.getValue
}

class Child extends TFirst with TSecond with CThird

应该调用TFirst.getValueTSecond.getValue的顺序?显然CThird已经编译,你不能改变它的super,所以它必须被移动到第一个位置,并且已经有TSecond.getValue调用它。但另一方面,这打破了左边的一切都是基础的规则,右边的一切都是孩子。不引入这种混淆的最简单方法是强制执行非特质类必须先行的规则。

如果您只是通过用扩展它的class CThird替换trait来扩展前面的示例,则适用相同的逻辑:

trait TFourth extends CThird
class AnotherChild extends TFirst with TSecond with TFourth

同样,唯一的非特质类AnotherChild可以扩展是CThird,这再次使冲突解决规则很难推理。

这就是为什么Scala使规则更简单:无论提供哪种基类必须来自第一个位置。然后将相同的规则扩展到特征上是有意义的,因此如果第一个位置被某个特征占用 - 它还定义了基类。


1
投票

1)基本上是的,你的理解是正确的。就像在Java中一样,每个类都继承自java.lang.Object(Scala中的AnyRef)。因此,既然您正在定义一个具体的类,那么您将隐式继承自Object。如果你用REPL检查,你得到:

scala> trait Base1 {}
defined trait Base1

scala> trait Base2 {}
defined trait Base2

scala> class Sub extends Base1 with Base2 {}
defined class Sub

scala> classOf[Sub].getSuperclass
res0: Class[_ >: Sub] = class java.lang.Object

2)是的,从规范中的“特征”段落来看,这也适用于它们。在“模板”段落中我们有:

新的超类型必须至少有一个不带参数的构造函数

然后在“Traits”段落中:

与普通类不同,traits不能具有构造函数参数。此外,没有构造函数参数传递给特征的超类。这是没有必要的,因为在初始化超类之后初始化了traits。

假设特征D定义了类型C的实例x的某些方面(即D是C的基类)。然后,x中D的实际超类型是由L(C)中所有成功D的基类组成的复合类型。

这是定义没有参数的基础构造函数所必需的。

3)根据答案(2),需要定义基础构造函数

© www.soinside.com 2019 - 2024. All rights reserved.