Scala 的 Case Class 是一个失败的实验吗?






2.11/5 (5投票s)
一篇讨论 Scala 的 Case Class 的文章
引言
很久以前,Eiffel 的创造者 Bertrand Meyer 曾强烈反对使用 switch
和 case
。他认为这些语法结构不是面向对象的,并且会使将来的维护变得困难。为了让他说的更清楚,他决定 Eiffel 将不支持这些语句。
这个决定并没有得到 Eiffel 用户的好评,Meyer 最终还是屈服了,并在他的语言中添加了 switch
和 case
。
Meyer 最初的立场无疑是极端的,但我认为他论点的基础仍然站得住脚。今天,在参加 Martin Odersky 的 Jazoon 主题演讲时,特别是当他描述 Scala 的 "case classes" 时,我再次想起了这个古老的争论。
自从我第一次读到 case classes 的介绍以来,我就一直对它们的用途感到困惑,而且我的困惑并没有减轻。要么是我错过了什么重要的事情,要么是这个特性被极大地夸大了,应该尽可能地避免使用。
让我们来看看原因。
官方 Scala 文档中使用的示例
这是在 官方 Scala 文档 中使用的示例
def printTerm(term: Term) {
term match {
case Var(n) =>
print(n)
case Fun(x, b) =>
print("^" + x + ".")
printTerm(b)
case App(f, v) =>
Console.print("(")
printTerm(f)
print(" ")
printTerm(v)
print(")")
}
}
这段代码是一个假设的解析器的一部分:根据我们刚刚遇到的变量、函数或应用程序,它会执行不同的操作。
我对这种方法有两个强烈反对的理由:
- 它将这个类与各种情况中描述的所有类紧密地耦合在一起。
- 它使得演进变得困难。
我认为第一个论点相当明显:无论这段代码属于哪个类,该类都对它试图使用的所有类有非常多的了解。如果有一种替代方案可以解决这个问题,那么这就不会是大问题,我将在下面展示。
第二个论点稍微有些微妙,但实际上是最重要的。
显然,这个解析器很不完整:如果我想要支持 import 子句会怎么样?
首先,我可能需要创建至少一个新的类 Import,其次,我需要记住将它添加到这段代码中。更糟糕的是:所有这些代码只是打印出它刚刚解析的 Term
的类型。显然,我们需要更多的逻辑来实际完成工作,例如一个验证 Term
语法是否正确的函数(比如 verify()
),以及一个生成与该 term 相关的代码或抽象信息的函数(generateCode()
)。这些方法中的每一种也将包含一个 match 结构,需要为每个新类进行更新。
总而言之:每次我添加一个新类时,都需要记住更新我代码库中三个位置的函数。
现在我们明白了为什么 Meyer 如此不喜欢 switch
语句了……
解决这两个问题的首选方法是确保每个类都封装了它自己处理所有这些操作的逻辑。
interface Term {
void print();
void verify();
void generateCode();
}
public Var implements Term {
public void print() {
print(n)
}
public void verify() { ... }
public void generateCode() { ... }
}
// same for Fun and Expr
以下是我们使用 case classes 打印 term
的方法(同样来自 Scala 文档):
Term t = ...;printTerm(t)
以及通过恰当的封装:
Term t = ...;t.print();
通过 OO 方法,上面展示的代码就不再需要了!如果你需要打印 Term
的内容,只需在它上面调用 print()
即可。如果你在解析器中添加了一个新类,你需要实现的只是方便地在 Term
接口中进行总结:无需在整个代码库中寻找 switch
语句。我们不仅实现了干净的封装,还实现了局部性。
当我向一些 Scala 专家阐述这个论点的关键时,他们总体上同意我的观点,并指出 case classes 作为 Visitor 模式的替代方案更有用。
在关注这个特定情况之前,需要注意的是,此时我们正在解决一个狭窄的问题。而且,以我的经验来看,这是一个相当罕见的问题。如果这真的是 case classes 被发明的原因,那么我真的对将如此大的一个特性包含在语言中来解决如此小范围的问题感到困惑。
无论如何,让我们来看看 Visitor 的角度。
简而言之,Visitor 用于在不支持原生多重分派的语言中模拟多重分派。在某种程度上,你正在扩展虚拟调用,使其适用于传递给函数的参数的运行时类型。
我不会深入讨论这种方法的优缺点,但我只想指出,即使在我需要这种功能为数不多的几次中,一种类似于上述封装的方法通常也比 switch
语句更可取。换句话说,如果你需要根据函数是否与类型 A 或 B 的参数一起调用而执行不同的操作,最好将这个特定细节隐藏在处理该分派的类中,而不是在一个全局的 switch 中。
根据我对 Scala 历史的理解,case classes 的添加是为了支持 模式匹配,但在考虑了我刚刚给出的观点之后,我很难将 case classes 看作是成功的。它们不仅未能捕捉到 Prolog 和 Haskell 使之流行的强大模式匹配机制,而且实际上是从 OO 的角度来看是倒退了一步,这一点我知道 Martin 非常看重,并且这是 Scala 任务声明的完整部分。
公平地说,Scala 航行在非常艰难的水域:在无缝运行在现有 OO 平台和创造一门不仅能适应基本的函数式编程范式,还能为开放类等高级动态概念敞开大门之间找到一个好的折衷方案并非易事,总的来说,Scala 在找到正确的平衡方面做得很好。但不幸的是,在 case classes 上并非如此。
或者我错过了什么?