我正在阅读示例书中的Scala,几乎每个例子都有以下结构:
abstract class Stack[A] {
def push(x: A): Stack[A] = new NonEmptyStack[A](x, this)
def isEmpty: Boolean
def top: A
def pop: Stack[A]
}
class EmptyStack[A] extends Stack[A] {
def isEmpty = true
def top = error("EmptyStack.top")
def pop = error("EmptyStack.pop")
}
class NonEmptyStack[A](elem: A, rest: Stack[A]) extends Stack[A] {
def isEmpty = false
def top = elem
def pop = rest
}
我有以下两个相互关联的问题: 1)将空元素和非空元素表示为单独的类是一种常见的Scala实践吗?如果是,那为什么呢? 2)为什么孩子们在可能的情况下都实现了同样的愚蠢方法'isEmpty',对我来说,在父母班级中这样做更明智?
我想知道这里涉及的最深刻的哲学。
答案 0 :(得分:5)
1)是的,对于空容器和非空容器都有单独的类,这通常称为代数数据结构,但通常不那么明显。例如,Scala的List有两个类,Nil
表示一个空列表,::
包含一个元素和另一个列表。所以
List(1,2,3)
虽然通常由List[T]
特征引用,但实际上是:: [B] (hd: B, tl: List[B])
的实例,如下所示:
::(1, ::(2, ::(3, Nil)))
2)如果您注意到,每个类必须实现isEmpty
方法,每个子类的值都不同。它只是保存一些计算来确定Stack
的实例是否为空,因为每个子类型在编译时都已知道这一点。
答案 1 :(得分:3)
1)是的,这是Scala表达代数数据类型或区分联合的方式,在函数式编程语言中很常见。这里的替代方案是只有一个带有可选数据成员的类(使用Option
,它还有一个空子类和一个非空子类,或者使用null
)。这迫使你的所有方法必须检查对象是否实际上有数据,使它们更复杂;使用子类具有系统的虚拟方法调度(无论如何它将执行此操作)为您执行此检查。它还强制存在的数据一致; Stack
有elem
和rest
(NonEmptyStack
),或者没有(EmptyStack
)。它不可能有一个而不是另一个(假设没有人故意使用NonEmptyStack
null
,这在Scala非常罕见)。
数据类型的一般模式是几种情况之一,每种情况都附有不同的数据,可广泛应用。其中一个案例包含 no 数据只是这种一般模式的一个简单案例。作为一名Scala程序员,使用这种通用模式对您来说很熟悉,因此将其应用于简单案例似乎也很自然。
2)你会注意到每个子类中的所有方法会立即返回一个值,没有进一步的计算(除了错误情况,它会立即抛出异常而不进行进一步的计算) 。这使得它们非常明显且易于理解,只要您习惯于考虑虚拟方法调度。
此外,它使它们非常有效;确定每个方法应该返回什么所需的唯一计算是虚拟方法调度,无论如何系统将为您执行此操作。要在父类中实现isEmpty
,您必须添加某种形式的实例检查和分支;无论如何,这实际上只是系统虚拟方法调度的手动形式!
此外,我认为最重要的是,子级实现更易于维护。假设您添加了另一种特殊类型的非空堆栈(可能您有大量1个元素的堆栈,并且不希望浪费空间存储对空堆栈的额外引用,或者某些东西)。如果您在父级中有分支以返回不同子类的不同答案,则必须更新每个子类以考虑新的子类。 如果你不这样做,编译器可能不会注意到。如果你已经在子类中实现了每个子类特定的行为,那么你只需在新的子类中实现这些方法。
答案 2 :(得分:2)
它看起来类似于List
的实现。我只是在这里猜测,但这个例子可能会在本书的后续章节中继续进行,其中解释了模式匹配,因此实现将以case
关键字为前缀,以便您可以在Stack
上进行匹配s,因为您可以List
方式在case head :: tail =>
上匹配。
答案 3 :(得分:1)
那么,如果没有“空”子类,你会如何建议写这个?尝试写它(并记住你不能使用可变状态),你会发现它很难。我所知道的唯一方法需要私有构造函数。