(8) Case Classes and Sealed Types
About
:octocat: GitHub: All of the example code: repo (link)
:page_facing_up: blog link: https://purrgramming.life/cs/programming/fp/ :star:
Case Classes
Case classes are like regular classes with a few key differences, which we will go over. Case classes are good for modelling immutable data.
Defining a case class
A minimal case class requires the keywords case class
, an identifier, and a parameter list (which may be empty):
case class Book(isbn: String)
val frankenstein = Book("978-0486282114")
Notice how the keyword new
was not used to instantiate the Book
case class. This is because case classes have an apply
method by default which takes care of object construction.
When creating a case class with parameters, the parameters are public val
s.
case class Message(sender: String, recipient: String, body: String)
val message1 = Message("sender", "recipient", "body: hi")
message1.sender // val res0: String = sender
// this line does not compile
// Error msg: |Reassignment to val sender
message1.sender = "[email protected]"
You can’t reassign message1.sender
because it is a val
(i.e. immutable). It is possible to use var
s in case classes, but this is discouraged.
Comparison
Instances of case classes are compared by structure and not by reference:
val message1 = Message("sender", "recipient", "body: hi")
val message2 = Message("sender", "recipient", "body: hi")
message1 == message2 // val res1: Boolean = true
Even though message1
and message2
refer to different objects, the value of each object is equal.
Copying
You can create a (shallow) copy of an instance of a case class simply by using the copy
method. You can optionally change the constructor arguments.
val message3 = message1.copy(sender = "sender2")
message3 // val res2: Message = Message(sender2,recipient,body: hi)
Advantage
The main advantages of using case classes instead of classes are
- We can use pattern matching to access the arguments of the constructor that created an object
We can get a reference to the arguments of the constructor.
For example in
def eval(e: Expr): Int = e match
case Number(n) => n
case Sum(e1, e2) => eval(e1) + eval(e2)
- The compiler creates accessors for the attributes – The compiler creates a field accessor for each field of the case class
Not because
We are NOT using case classes instead of classes.
[F] Case classes can extend traits while classes cannot
[T] Both of them can
[F] use type tests in ‘match’ expressions to evaluate different expressions according to the runtime type of the value you “match” on
[T] Type tests can be used with both classes and case classes
Sealed Types
Intro
Scala’s final modifier makes a class or trait unavailable for an extension. Therefore, it’s quite restrictive. On the other hand, making a class public allows any other class to extend from it.
What if we want something in between these two? The sealed modifier in Scala comes to the rescue!
The sealed keyword controls the extension of classes and traits. Declaring a class or trait as sealed restricts the definition of its subclasses — we have to define them in the same source file.
package week2
sealed abstract class MultipleChoice
case class OptionA() extends MultipleChoice
case class OptionB() extends MultipleChoice
case class OptionC() extends MultipleChoice
abstract class MultipleChoiceSub extends MultipleChoice
If we try to extend a sealed trait outside of the parent class file, the compiler will throw an exception with a message:
illegal inheritance from sealed class.
Error:(5, 32) illegal inheritance from sealed class MultipleChoice
case class OptionD() extends MultipleChoice
However, there is no restriction in extending from a subclass of a sealed class:
import week2.MultipleChoice
import week2.MultipleChoiceSub
// Error: Cannot extend sealed class MultipleChoice in a different source file
case class OptionD() extends MultipleChoice
case class OptionY() extends MultipleChoiceSub //This is valid
Motivation
Using sealed classes, we can guarantee that only subclasses defined in the file exist. This helps the compiler knows all the subclasses of the sealed class.
Example
The compiler can emit warnings in case the match cases are not exhaustive, to prevent a MatchError exception. Let’s try to omit OptionC from our pattern match and observe the compiler behavior:
def selectOption(option: MultipleChoice): String = option match {
case optionA: OptionA => "Option-A Selected"
case optionB: OptionB => "Option-B Selected"
}
We get a nice warning message from the compiler because of non-exhaustive match cases:
Warning:(11, 54) match may not be exhaustive.
It would fail on the following input: OptionC()
def selectOption(option: MultipleChoice): String = option match {