Are there situations where you should prefer a non-case class?
Martin Odersky gives us a good starting point in his course Functional Programming Principles in Scala (Lecture 4.6 - Pattern Matching) that we could use when we must choose between class and case class.
The chapter 7 of Scala By Example contains the same example.
Say, we want to write an interpreter for arithmetic expressions. To
keep things simple initially, we restrict ourselves to just numbers
and + operations. Such expres- sions can be represented as a class
hierarchy, with an abstract base class Expr as the root, and two
subclasses Number and Sum. Then, an expression 1 + (3 + 7) would be represented as
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
}
Furthermore, adding a new Prod class does not entail any changes to existing code:
class Prod(e1: Expr, e2: Expr) extends Expr {
def eval: Int = e1.eval * e2.eval
}
In contrast, add a new method requires modification of all existing classes.
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(")")
}
}
The same problem solved with 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
Adding a new method is a local change.
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(")")
}
}
}
Adding a new Prod class requires potentially change all pattern matching.
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) => ...
}
}
Transcript from the videolecture 4.6 Pattern Matching
Both of these designs are perfectly fine and choosing between them is sometimes a matter of style, but then nevertheless there are some criteria that are important.
One criteria could be, are you more often creating new sub-classes of expression or are you more often creating new methods? So it's a criterion that looks at the future extensibility and the possible extension pass of your system.
If what you do is mostly creating new subclasses, then actually the object oriented decomposition solution has the upper hand. The reason is that it's very easy and a very local change to just create a new subclass with an eval method, where as in the functional solution, you'd have to go back and change the code inside the eval method and add a new case to it.
On the other hand, if what you do will be create lots of new methods, but the class hierarchy itself will be kept relatively stable, then pattern matching is actually advantageous. Because, again, each new method in the pattern matching solution is just a local change, whether you put it in the base class, or maybe even outside the class hierarchy. Whereas a new method such as show in the object oriented decomposition would require a new incrementation is each sub class. So there would be more parts, That you have to touch.
So the problematic of this extensibility in two dimensions, where you might want to add new classes to a hierarchy, or you might want to add new methods, or maybe both, has been named the expression problem.
Remember: we must use this like a starting point and not like the only criteria.