声明Scala case类有哪些缺点?

107

如果你正在编写使用许多漂亮的不可变数据结构的代码,那么 case class 看起来是一种救星,只需使用一个关键字就可以提供以下所有内容:

  • 默认情况下所有都是不可变的
  • 自动定义 getters
  • 不错的 toString() 实现
  • 符合规范的 equals() 和 hashCode()
  • 具有匹配的 unapply() 方法的伴生对象

但是将不可变数据结构定义为 case class 有什么缺点?

它对类或其客户端施加了哪些限制?

是否存在应该优先选择非 case class 的情况?


请参考以下相关问题:https://dev59.com/kVPTa4cB1Zd3GeqPhTp3 - David
19
为什么这不是建设性的?这个网站的版主太严格了。这有一个有限数量的可能的事实回答。 - Eloff
5
同意Eloff的观点。这是我想要答案的问题,提供的答案非常有用,而且不显得主观。我看到很多“如何修复我的代码片段”之类的问题引发更多的辩论和观点。 - Herc
5个回答

101

首先,好的部分:

默认不可变

是的,如果需要可以使用var进行覆盖

自动定义getter方法

通过在参数前缀中使用val可以在任何类中实现

不错的toString()实现

非常有用,但如果必要的话,任何类都可以手工实现

符合规范的equals()hashCode()

结合易于模式匹配,这是人们使用case类的主要原因

带有unapply()方法的伴生对象用于匹配

在任何类中使用提取器也可以手工实现

此列表还应包括超强大的复制方法,这是Scala 2.8中最好的功能之一


然后是坏的部分,case类只有少数几个真正的限制:

无法使用与编译器生成方法相同的签名在伴生对象中定义apply

实际上,这很少是一个问题。更改生成的apply方法的行为保证会让用户感到惊讶,因此应该强烈反对这么做,唯一正当的理由是验证输入参数-最好在主构造函数体中执行此任务(这样在使用copy时也可以使用验证)

无法继承子类

是的,尽管case类本身仍然可以是后代。一个常见的模式是构建特征的类层次结构,使用case类作为树的叶节点。

值得注意的是sealed修饰符。带有此修饰符的trait的任何子类必须在同一文件中声明。当进行模式匹配时,编译器可以警告你是否未检查所有可能的具体子类。与case类结合使用时,如果代码能够无警告编译通过,则可以为您提供非常高的代码信心水平。

作为Product的子类,case类不能拥有超过22个参数

没有真正的解决方法,除了停止滥用这么多参数的类 :)

此外...

另一个限制是Scala(目前)不支持惰性参数(例如lazy val,但作为参数)。对此的解决方法是在构造函数中使用按名称的参数并将其赋值给一个lazy val。不幸的是,按名称的参数不能与模式匹配混合使用,这会破坏编译器生成的提取器,从而防止在case类中使用该技术。

这在实现高度功能化的惰性数据结构时很重要,并且希望通过将惰性参数添加到Scala的未来版本中来解决该问题。


1
感谢您提供的全面回答。我认为除了“您无法进行子类化”之外,其他所有内容都不太可能在短期内影响到我。 - Graham Lea
15
您可以对一个case class 进行子类化。子类不能再是一个 case class,这是限制条件。 - Seth Tisue
5
在Scala 2.11中,对于case类的22个参数限制已被取消。 - Jonathan Crosmer
1
断言“您无法使用与编译器生成的方法相同的签名在伴生对象中定义apply”是不正确的。虽然这需要跳过一些障碍(如果您打算保留以前由Scala编译器隐式生成的功能),但肯定可以实现:https://dev59.com/fG025IYBdhLWcg3wvIko#25538287 - chaotic3quilibrium
我已经广泛使用Scala case类,并提出了一个“case类模式”(最终会成为Scala宏),它有助于解决上述若干问题:http://codereview.stackexchange.com/a/98367/4758 - chaotic3quilibrium

51

一个很大的缺点:case类不能继承case类。这是限制条件。

其他你错过的优点,列举完整:符合序列化/反序列化标准,不需要使用 "new" 关键字来创建。

在具有可变状态、私有状态或没有状态的对象(例如大多数单例组件)中,我更喜欢非case类。而对于几乎所有其他情况,我则倾向于使用case类。


48
您可以对一个 case class 进行子类化。子类不能再是一个 case class,这是限制条件。 - Seth Tisue

11

我认为TDD原则适用于这里:不要过度设计。当你声明某个类为case class时,你就声明了很多功能。这将降低你在未来更改类时的灵活性。

例如,case class具有基于构造函数参数的equals方法。你可能一开始写类时并不关心这一点,但是后来可能会决定忽略其中一些参数或进行一些不同的操作。然而,在此期间编写的客户端代码可能依赖于case class的相等性。


4
我认为客户端代码不应依赖于“equals”的确切含义;这取决于类来决定它的“equals”意味着什么。类的作者应该可以自由地在未来更改“equals”的实现。 - pkaeding
8
你可以选择让客户端代码不依赖于任何私有方法。公开的一切都是你已经同意的契约。 - Daniel C. Sobral
3
@DanielC.Sobral 的说法没错,但 equals() 方法的具体实现(基于哪些字段)并没有必须在契约中列出。至少,在编写类时,你可以明确将其排除在契约之外。 - herman
1
@herman 合同是代码所做的一切,无论是否记录,并且甚至包括您告诉人们不应依赖的内容。如果使用代码足够多,则客户将依赖于代码执行的任何操作。我的观点是,您提供的任何等号最终都会被依赖 - 即使是引用相等,如果您编写一个类并且不重写它,它也是如此。因此,您添加的代码越少,其他代码可以依赖的内容就越少 - 在实际知道需要执行什么操作之前,您无法编写等式,这是问题的前提。 - Daniel C. Sobral
2
@DanielC.Sobral 你自相矛盾:你说人们甚至会依赖于默认的equals实现(比较对象标识)。如果这是真的,而你后来编写了不同的equals实现,他们的代码也会出问题。无论如何,如果你指定了前/后条件和不变量,而人们忽略了它们,那就是他们的问题。 - herman
2
@herman 我所说的并没有矛盾之处。至于“他们的问题”,当然,除非它变成了_你的_问题。比如,因为他们是你创业公司的重要客户,或者因为他们的经理说服了高层管理层认为更改成本太高,所以你必须撤销你的更改,或者因为更改导致了数百万美元的错误并被撤销等等。但是,如果你只是为了爱好写代码而不关心用户,那就随便吧。 - Daniel C. Sobral

7
你是否应该优先选择非case类?
Martin Odersky在他的课程Scala函数式编程原理(Lecture 4.6 - 模式匹配)中给了我们一个很好的起点,当我们必须在类和case类之间进行选择时,我们可以使用这个起点。Scala By Example的第7章包含了相同的例子。
假设我们想为算术表达式编写一个解释器。为了保持简单,我们最初只限制于数字和+操作。这样的表达式可以表示为一个类层次结构,其中抽象基类Expr为根,两个子类Number和Sum。然后,一个表达式1 +(3 + 7)将被表示为 new Sum(new Number(1),new Sum(new Number(3),new Number(7)))
abstract class Expr {
  def eval: Int
}

class Number(n: Int) extends Expr {
  def eval: Int = n
}

class Sum(e1: Expr, e2: Expr) extends Expr {
  def eval: Int = e1.eval + e2.eval
}

此外,添加一个新的Prod类不需要对现有代码进行任何更改:
class Prod(e1: Expr, e2: Expr) extends Expr {
  def eval: Int = e1.eval * e2.eval
}

相比之下,添加新方法需要修改所有现有的类。
abstract class Expr { 
  def eval: Int 
  def print
} 

class Number(n: Int) extends Expr { 
  def eval: Int = n 
  def print { Console.print(n) }
}

class Sum(e1: Expr, e2: Expr) extends Expr { 
  def eval: Int = e1.eval + e2.eval
  def print { 
   Console.print("(")
   print(e1)
   Console.print("+")
   print(e2)
   Console.print(")")
  }
}

使用 case classes 解决相同的问题。

abstract class Expr {
  def eval: Int = this match {
    case Number(n) => n
    case Sum(e1, e2) => e1.eval + e2.eval
  }
}
case class Number(n: Int) extends Expr
case class Sum(e1: Expr, e2: Expr) extends Expr

添加新方法是一项本地更改。

abstract class Expr {
  def eval: Int = this match {
    case Number(n) => n
    case Sum(e1, e2) => e1.eval + e2.eval
  }
  def print = this match {
    case Number(n) => Console.print(n)
    case Sum(e1,e2) => {
      Console.print("(")
      print(e1)
      Console.print("+")
      print(e2)
      Console.print(")")
    }
  }
}

添加一个新的Prod类可能需要更改所有模式匹配。

abstract class Expr {
  def eval: Int = this match {
    case Number(n) => n
    case Sum(e1, e2) => e1.eval + e2.eval
    case Prod(e1,e2) => e1.eval * e2.eval
  }
  def print = this match {
    case Number(n) => Console.print(n)
    case Sum(e1,e2) => {
      Console.print("(")
      print(e1)
      Console.print("+")
      print(e2)
      Console.print(")")
    }
    case Prod(e1,e2) => ...
  }
}

视频讲座4.6模式匹配的文字记录

这两种设计都可以,选择其中一种有时取决于风格,但仍有一些重要的标准。一个标准可能是,您更经常创建表达式的子类还是更经常创建新方法?因此,这是一个关注系统未来可扩展性和可能的扩展传递的标准。如果您所做的大多数是创建新的子类,则面向对象的分解解决方案具有优势。原因是只需创建一个具有eval方法的新子类即可,这是非常容易和非常局部的更改;而在函数式解决方案中,您必须返回并更改eval方法内的代码并添加一个新的case。另一方面,如果您将创建大量新方法,但类层次结构本身将保持相对稳定,则模式匹配实际上是有利的。因为,在模式匹配解决方案中,每个新方法只是一个局部更改,无论您将其放在基类中,还是甚至在类层次结构之外。而在面向对象的分解中,例如show中的新方法将需要在每个子类中进行新的增量。因此,存在两个维度的可扩展性问题,您可能希望将新类添加到层次结构中,或者可能希望添加新方法,或者两者都希望,这被称为表达式问题。

记住:我们必须把这个当作起点,而不是唯一的标准。

enter image description here


1

我从Alvin Alexander的Scala cookbook第6章中引用了这段内容。

这是我在这本书中发现的许多有趣之处之一。

为了为一个case class提供多个构造函数,重要的是要知道case class声明实际上做了什么。

case class Person (var name: String)

如果你查看Scala编译器为案例类示例生成的代码,你会发现它创建了两个输出文件,Person$.class和Person.class。如果你使用javap命令反汇编Person$.class,你会看到它包含一个apply方法,以及许多其他方法。
$ javap Person$
Compiled from "Person.scala"
public final class Person$ extends scala.runtime.AbstractFunction1 implements scala.ScalaObject,scala.Serializable{
public static final Person$ MODULE$;
public static {};
public final java.lang.String toString();
public scala.Option unapply(Person);
public Person apply(java.lang.String); // the apply method (returns a Person) public java.lang.Object readResolve();
        public java.lang.Object apply(java.lang.Object);
    }

你也可以反汇编Person.class来查看其包含的内容。对于像这样简单的类,它包含额外的20个方法;这种隐藏的膨胀是一些开发人员不喜欢case类的原因之一。

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