如果你正在编写使用许多漂亮的不可变数据结构的代码,那么 case class 看起来是一种救星,只需使用一个关键字就可以提供以下所有内容:
- 默认情况下所有都是不可变的
- 自动定义 getters
- 不错的 toString() 实现
- 符合规范的 equals() 和 hashCode()
- 具有匹配的 unapply() 方法的伴生对象
但是将不可变数据结构定义为 case class 有什么缺点?
它对类或其客户端施加了哪些限制?
是否存在应该优先选择非 case class 的情况?
如果你正在编写使用许多漂亮的不可变数据结构的代码,那么 case class 看起来是一种救星,只需使用一个关键字就可以提供以下所有内容:
但是将不可变数据结构定义为 case class 有什么缺点?
它对类或其客户端施加了哪些限制?
是否存在应该优先选择非 case class 的情况?
首先,好的部分:
默认不可变
是的,如果需要可以使用var
进行覆盖
自动定义getter方法
通过在参数前缀中使用val
可以在任何类中实现
不错的toString()
实现
非常有用,但如果必要的话,任何类都可以手工实现
符合规范的equals()
和hashCode()
结合易于模式匹配,这是人们使用case类的主要原因
带有unapply()
方法的伴生对象用于匹配
在任何类中使用提取器也可以手工实现
此列表还应包括超强大的复制方法,这是Scala 2.8中最好的功能之一
然后是坏的部分,case类只有少数几个真正的限制:
apply
实际上,这很少是一个问题。更改生成的apply方法的行为保证会让用户感到惊讶,因此应该强烈反对这么做,唯一正当的理由是验证输入参数-最好在主构造函数体中执行此任务(这样在使用copy
时也可以使用验证)
是的,尽管case类本身仍然可以是后代。一个常见的模式是构建特征的类层次结构,使用case类作为树的叶节点。
值得注意的是sealed
修饰符。带有此修饰符的trait的任何子类必须在同一文件中声明。当进行模式匹配时,编译器可以警告你是否未检查所有可能的具体子类。与case类结合使用时,如果代码能够无警告编译通过,则可以为您提供非常高的代码信心水平。
没有真正的解决方法,除了停止滥用这么多参数的类 :)
另一个限制是Scala(目前)不支持惰性参数(例如lazy val
,但作为参数)。对此的解决方法是在构造函数中使用按名称的参数并将其赋值给一个lazy val。不幸的是,按名称的参数不能与模式匹配混合使用,这会破坏编译器生成的提取器,从而防止在case类中使用该技术。
这在实现高度功能化的惰性数据结构时很重要,并且希望通过将惰性参数添加到Scala的未来版本中来解决该问题。
一个很大的缺点:case类不能继承case类。这是限制条件。
其他你错过的优点,列举完整:符合序列化/反序列化标准,不需要使用 "new" 关键字来创建。
在具有可变状态、私有状态或没有状态的对象(例如大多数单例组件)中,我更喜欢非case类。而对于几乎所有其他情况,我则倾向于使用case类。
我认为TDD原则适用于这里:不要过度设计。当你声明某个类为case class
时,你就声明了很多功能。这将降低你在未来更改类时的灵活性。
例如,case class
具有基于构造函数参数的equals
方法。你可能一开始写类时并不关心这一点,但是后来可能会决定忽略其中一些参数或进行一些不同的操作。然而,在此期间编写的客户端代码可能依赖于case class
的相等性。
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
}
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中的新方法将需要在每个子类中进行新的增量。因此,存在两个维度的可扩展性问题,您可能希望将新类添加到层次结构中,或者可能希望添加新方法,或者两者都希望,这被称为表达式问题。记住:我们必须把这个当作起点,而不是唯一的标准。
我从Alvin Alexander的Scala cookbook第6章中引用了这段内容。
这是我在这本书中发现的许多有趣之处之一。
为了为一个case class提供多个构造函数,重要的是要知道case class声明实际上做了什么。
case class Person (var name: String)
$ 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);
}