The case for case objects in Scala
by way of Settlers of Catan
Have you ever wondered what a case object actually is in Scala? Yeah, me neither! But then someone asked me the following question on LinkedIn:
What is the difference between a case object and a regular object?
… and I realized I didn’t really know! My knee jerk response was that case objects are just singletons used as case
patterns in a match statement.
@ case object Sheep
defined object Sheep
@ val s = Sheep
f: Sheep.type = Sheep
@ s match { case Sheep => "got a Sheep" }
res3: String = "got a Sheep"
While true, this answer seems insufficient. To most Scala newcomers the idea of case objects and case classes seem exotic. Like we know they’re special somehow, we’re just not sure why.
So what really is a case object
?? Do they do anything useful? For that matter, what is so interesting about case classes that they deserve their own concept and syntax?
Let’s find out!
Case Classes
Before we get to case objects, let’s quickly review the concept of a case class.
The official Scala docs have a great introduction to case classes, but this succinct definition really sums up their importance:
Case classes are used to model immutable data structures.
Boom. Read that again.
The sky’s the limit with what case classes allow you to model! As an example, zoom in on this image to see parts of an expressive, type safe model for the game Settlers of Catan.
This is an incredibly powerful concept, so you can understand why case classes are used all the time in Scala. As such, they contain several user-friendly features that add up to significant time savings in the long run:
- You don’t need to type
new
to instantiate a class (apply
is the constructor) - Since they are immutable, they have a
copy
method to easily create new instances - They have built in
equals
method so that case classes with the same data are considered equal - They have a default
unapply
method useful in pattern matching - They have a default
toString
method useful for debugging - They are
Serializable
for machine-to-machine communication
(There are some other tidbits from the Product
and Equals
traits thrown in here as well, like productArity
, and canEqual
etc, but the above are the more noticable features)
Let’s create a simple Sheep
model to highlight each of these with toy examples
@ case class Sheep(name: String, favoriteThing: String)
defined class Sheep
// create a new instance without `new`
@ val shaun = Sheep("Shaun", "eating wheat!")
sean: Sheep(name = "Shaun", favoriteThing = "eating wheat!")
// easily create a new immutable object with `copy`
@ shaun.copy(name = "Shorn")
res13: Sheep(name = "Shorn", favoriteThing = "eating wheat!")
// treat the same data as equal (this is not how normal classes behave)
@ shaun == Sheep("Shaun", "eating wheat!")
res14: Boolean = true
// `unapply` for easily matching inside the case class
@ shaun match { case Sheep(_, fav) => fav.toUpperCase }
res15: String = "EATING WHEAT!"
// prettified `toString` for debugging
@ s"my sheep is $shaun"
res16: String = "my sheep is Sheep(Shaun,eating wheat!)"
// we'll cover serialization more below
This may not look like much, but on the whole it makes it almost trivial to model out complex domains and then focus on actually wiring up the application logic.
On the other hand, here’s what it looks like if you wanted to build a case class yourself with all the same functionality:
class Sheep(val name: String, val favoriteThing: String) extends Product2[String,String] with Serializable {
def _1: String = name
def _2: String = favoriteThing
def canEqual(o: Any): Boolean = o match {
case _: Sheep => true
case _ => false
}
def copy(name: String = this.name, favoriteThing: String = this.favoriteThing) = new Sheep(name, favoriteThing)
override def toString(): String = s"Sheep($name,$favoriteThing)"
override def equals(o: Any): Boolean = o match {
case Sheep(n,f) => name == n && favoriteThing == f
case _ => false
}
}
object Sheep {
def apply(name: String, favoriteThing: String) = new Sheep(name, favoriteThing)
def unapply(sheep: Sheep): Option[Tuple2[String,String]] = Some(name, favoriteThing)
}
Sure, it’s doable, but who’s going to do that when they can do this?!
case class Sheep(name: String, favoriteThing: String)
Now that you’re all caught up on case classes and how they’re used, let’s get into case objects. Spoiler alert: they’re basically the just singleton version of case classes!
Case Objects
If case classes are used to model immutable data structures, case objects are used to model singleton ideas, where the singleton itself is the data. The canonical example is you want to model the commands you might send to a remote app:
sealed trait Action
case object Roll extends Action
case AcceptTrade(from: Player) extends Action
In this case (no pun intended), the Roll
command doesn’t need any extra arguments so it also doesn’t need any fancy apply
, copy
or unapply
methods — there are no arguments to manipulate. Additionally, there’s no need for equals
because singletons can only be defined once.
Clearly case objects, like their case class cousins, are powerful tools to model complex domains, but do we really need a special syntax for this? The scala docs state that case objects are somehow “more powerful” than normal objects, particularly when pattern matching.
However, we can pattern match with normal singleton objects as well:
@ object Wheat
defined object Wheat
@val w = Wheat
w: Wheat.type = ammonite.$sess.cmd1$Wheat$@14ef2482
@ w match { case Wheat => "got a Wheat"
res18: String = "got a Wheat"
So, what are these magical features that make case objects so special?
Basically, since apply
, unapply
, copy
and equals
aren’t applicable for singletons, only two potential differences remain:
- They have an improved
toString
implementation - They’re serializable
Improved toString
You have already seen this above, but similar to case classes vs normal classes, the string representation of a case object is much cleaner and easier to read in stack traces and debug logs.
@ object Brick
defined object Brick
@ case object Rock
defined Rock
@ s"hey, i'm a $Brick"
res21: String = "hey, i'm a ammonite.$sess.cmd94$Brick$@4e79d1fd"
@ s"hey, i'm a $Rock"
res22: String = "hey, i'm a Rock"
Note: Brick.toString
would look something like Brick$@4e70d1fd
when run outside of Ammonite.
Serialization
As you’re likely aware, Serialization allows you to turn the in-memory representation of computer code into something that can be easily stored to disk or transferred to another machine. Being able to serialize code is a critical feature when sending messages (think Akka) or compiled programs (think Apache Spark) to and from other machines.
There are other serialization libraries like Kryo or Protocol Buffers, but as a builtin convenience Scala uses Java’s serialization under the hood:
@ import java.io._
import java.io._
@ def serialize(obj:AnyRef):Seq[Byte] = {
val buff = new ByteArrayOutputStream()
val outStream = new ObjectOutputStream(buff)
outStream.writeObject(obj)
outStream.close()
buff.toByteArray
}
defined function serialize
Starting in Scala 3 normal objects are serializable by default, but in scala 2.13 and earlier this wasn’t the case.
In scala 2.13, if you tried to serialize our case object Rock
and object Brick
singletons from above, you’d see that you can serialize Rocks but not Bricks:
@ serialize(Rock)
res26: Seq[Byte] = ArraySeq(
-84,
-19,
0,
5,
115,
114,
0,
38,
115,
99,
97,
108,
97,
46,
114,
117,
110,
116,
...
@ serialize(Brick)
java.io.NotSerializableException: ammonite.$sess.cmd1$Bar$
java.io.ObjectOutputStream.writeObject0(ObjectOutputStream.java:1175)
java.io.ObjectOutputStream.writeObject(ObjectOutputStream.java:345)
ammonite.$sess.cmd71$.serialize(cmd71.sc:4)
ammonite.$sess.cmd72$.<clinit>(cmd72.sc:1)
To see why this might be a problem, let’s return to our commands for things to do in Catan and add a non-serializable EndTurn
action:
sealed trait Action
case object Roll extends Action
case AcceptTrade(from: Player) extends Action
object EndTurn extends Action
If you’re not careful when sending these commands over the network to your Catan server, you may run into a NotSerializableException
crashing at runtime whenever an EndTurn
action is sent to the server.
You can fix this with
sealed trait Action extends Serializable
or
object EndTurn extends Action with Serializable
Or just use case object
from the start, keeping things clean and worry-free:
sealed trait Action
case object Roll extends Action
case AcceptTrade(from: Player) extends Action
case object EndTurn extends Action
Again, since objects are now serializable in Scala 3, this isn’t really an issue any more.
Bringing it home
Now that you understand all the subtle nuances between case objects, case classes and their ‘normal’ brothers, here’s an unexpected turn:
Under the hood, case objects are basically just case classes anyway!
Seriously, you can see this when looking at the typer output of scalac
when you compile a simple foo.scala
file like this:
case object CaseObject
case class CaseClass()
Running scalac -Xprint:typer foo.scala
will show that CaseObject is turned into a zero-argument case class, so it inherits all the same goodies that case class
does from Product
and Serializable
:
final lazy module case val CaseObject: CaseObject = new CaseObject()
final module case class CaseObject() extends Object(), _root_.scala.Product,
_root_.scala.Serializable { this: CaseObject.type =>}
Similarly, the CaseClass
will show the extra copy
method, and the apply
, unapply
, and toString
convenience methods that we talked about earlier.
case class CaseClass() extends Object(), _root_.scala.Product, _root_.scala.Serializable
{
def copy(): CaseClass = new CaseClass()
}
final lazy module val CaseClass: CaseClass = new CaseClass()
final module class CaseClass() extends AnyRef() { this: CaseClass.type =>
def apply(): CaseClass = new CaseClass()
def unapply(x$1: CaseClass): true.type = true
override def toString: String = "CaseClass"
}
Conclusion
- Case objects are basically zero argument case classes used for modeling complex domains.
- Unlike normal objects, case objects are serializable and have a pretty
toString
implementation. Since normal objects are now serializable in Scala 3, the only real difference you’re likely to see is the defaulttoString
implementation in case objects. - Both case objects and case classes are essential to why Scala is a flexible and expressive language for modeling complex domains.
And that’s it! Now you know more than you’ve ever wanted to about a small yet important part of the Scala language.