声明Scala案例类有什么缺点?


105

如果您正在编写使用许多漂亮的,不变的数据结构的代码,那么case类似乎是天赐之物,只需一个关键字即可免费为您提供以下所有功能:

  • 默认情况下所有内容都是不可变的
  • 自动定义吸气剂
  • 体面的toString()实现
  • 兼容的equals()和hashCode()
  • 使用unapply()方法进行匹配的随播对象

但是将不可变数据结构定义为案例类的缺点是什么?

它对班级或其客户有什么限制?

在某些情况下,您应该偏爱非案例类吗?


看到这个相关问题:stackoverflow.com/q/4635765/156410
David

18
为什么这不是建设性的?这个网站上的模组太严格了。这有一定数量的可能的事实答案。
Eloff 2015年

5
同意Eloff。这也是我也想要一个答案的问题,所提供的答案非常有用,而且不会显得主观。我已经看到许多“如何修正我的代码摘录”问题,引起了更多的辩论和意见。
Herc 2015年

Answers:


51

一个很大的缺点:案例类不能扩展案例类。那是限制。

为完整性起见,您还错过了其他优点:兼容的序列化/反序列化,无需使用“ new”关键字来创建。

对于具有可变状态,私有状态或无状态(例如,大多数单例组件)的对象,我更喜欢非大小写类。案例类几乎涵盖了其他所有内容。


48
您可以对案例类进行子类化。子类也不能是case类-这是限制。
塞斯·提苏

99

首先是好地方:

默认情况下所有内容都是不可变的

是的,var如果需要,甚至可以覆盖(使用)

自动定义吸气剂

在任何类中都可以通过在参数前面加上前缀 val

体面的toString()实施

是的,非常有用,但如有需要,可在任何班级手工完成

符合equals()hashCode()

结合简单的模式匹配,这是人们使用案例类的主要原因

随播对象及其unapply()匹配方法

也可以使用提取器在任何班级上手工完成

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


不好的是,案例类只有很少的实际限制:

您不能apply在随播对象中使用与编译器生成的方法相同的签名进行定义

但是实际上,这很少有问题。保证更改生成的apply方法的行为会让用户感到吃惊,因此不建议这样做,这样做的唯一理由是验证输入参数-最好在主构造函数主体中完成的任务(使用时还可以进行验证copy

你不能继承

是的,尽管案例类本身仍然有可能成为后代。一种常见的模式是使用案例类作为树的叶子节点来构建特征的类层次结构。

值得注意的是sealed修饰符。具有此修饰符的特征的任何子类都必须在同一文件中声明。当对特征的实例进行模式匹配时,如果您尚未检查所有可能的具体子类,则编译器会警告您。如果将其与案例类结合使用,则可以在没有警告的情况下进行编译,从而使您对代码有很高的信心。

作为Product的子类,案例类的参数不能超过22个

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

也...

有时注意到的另一个限制是,Scala(当前)不支持惰性参数(如lazy vals,但作为参数)。解决方法是使用按名称的参数并将其分配给构造函数中的惰性val。不幸的是,按名称命名的参数不会与模式匹配混合使用,这会阻止该技术与案例类一起使用,因为它破坏了编译器生成的提取器。

如果您要实现功能强大的惰性数据结构,则这是相关的,并希望在以后的Scala版本中添加惰性参数来解决。


1
感谢您的全面答复。我认为“您不能继承”的所有例外情况都不太可能在短期内出现。
Graham Lea

15
您可以对案例类进行子类化。子类也不能是case类-这是限制。
塞斯·提苏

5
案例类的22参数限制已在Scala 2.11中删除。issue.scala-lang.org/browse/SI-7296
乔纳森·

断言“您不能使用与编译器生成的方法相同的签名来定义在伴随对象中应用apply”是不正确的。尽管这样做需要跳过一些步骤(如果您打算保留曾经由scala编译器不可见地生成的功能),则可以肯定可以实现:stackoverflow.com/a/25538287/501113
chaotic3quilibrium

我一直在广泛使用Scala案例类,并提出了一个“案例类模式”(最终将最终成为Scala宏),它可以解决上述一些问题:codereview.stackexchange.com/a/98367 / 4758
chaotic3quilibrium,2015年

10

我认为TDD原则在这里适用:不要过度设计。当您将某物case class声明为时,就说明了许多功能。这将降低您将来更改班级时的灵活性。

例如,一个case class具有equals构造函数参数的方法。初次编写类时,您可能并不在意,但后者可能决定让您希望相等性忽略其中的某些参数,或者执行一些其他操作。但是,客户端代码可能在同时编写,这取决于case class相等性。


4
我认为客户端代码不应该取决于“等于”的确切含义。由一个阶级来决定“相等”对它意味着什么。班级作者应该自由地改变“等于”的实现方式。
2011年

8
@pkaeding您可以自由地使客户端代码不依赖于任何私有方法。公开的一切都是您已同意的合同。
Daniel C. Sobral

3
@ DanielC.Sobral是的,但是equals()的确切实现(它基于的字段)不一定在合同中。至少,当您第一次编写该类时,可以将其明确排除在合同之外。
Herman 2013年

2
@ DanielC.Sobral您在矛盾自己:您说人们甚至将依赖默认的equals实现(它比较对象标识)。如果是这样,并且您稍后编写了另一个equals实现,则它们的代码也会中断。无论如何,如果您指定前后条件和不变式,而人们却忽略了它们,那就是他们的问题。
Herman 2013年

2
@herman我的意思并不矛盾。至于“他们的问题”,当然,除非它成为您的问题。举例来说,是因为他们是创业公司的庞大客户,或者是因为他们的经理说服高层管理人员更改成本太高,所以您必须撤消更改,或者因为更改会导致数百万美元的损失错误并被还原,等等。但是,如果您是出于爱好而编写代码,而不关心用户,请继续。
Daniel C. Sobral

7

在某些情况下,您应该偏爱非案例类吗?

Martin Odersky在他的《 Scala中的函数式编程原理》(第4.6节-模式匹配)课程中为我们提供了一个很好的起点,当我们必须在类和案例类之间进行选择时,可以使用它。Scala示例示例的第7章包含相同的示例。

说,我们想为算术表达式编写一个解释器。为了使事情一开始很简单,我们将自己限制为数字和+运算。这样的表达式可以表示为一个类层次结构,以抽象基类Expr为根,以及两个子类Number和Sum。然后,表达式1 +(3 + 7)将表示为

新总和(新数字(1),新总和(新数字(3),新数字(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(")")
  }
}

案例类解决了相同的问题。

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方法中的代码并添加一个新案例对它。

另一方面,如果您要做的是创建许多新方法,但是类层次结构本身将保持相对稳定,则模式匹配实际上是有利的。同样,因为模式匹配解决方案中的每个新方法都只是局部更改,无论您将其放在基类中,甚至可能放在类层次结构之外。每个子类都是一个新方法,例如在面向对象分解中使用show,它需要一个新的增量。所以会有更多的部分,您必须触摸。

因此,这种在二维上具有可扩展性的问题(您可能想在层次结构中添加新的类,或者您想添加新的方法,或者两者都被称为表达式问题)

请记住:我们必须以此为起点,而不是唯一的标准。

在此处输入图片说明


0

我从这个引用Scala cookbookAlvin Alexander第6章:objects

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

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

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个方法。这种隐藏的膨胀是某些开发人员不喜欢案例类的原因之一。

By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.