(13) Functional Type Handling
About
:octocat: GitHub: All of the example code: repo (link)
:page_facing_up: blog link: https://purrgramming.life/cs/programming/fp/ :star:
Type Erasure
Definition
Scala: statically typed programming, i.e. The compiler determines the type of a variable at compile time.
Type erasure is the load-time process by which explicit type annotations are removed from a program before execution at run-time.
Operational semantics that does not require programs to be accompanied by types are type-erasure semantics, contrasted with type-passing semantics.
Why
After compilation, the compiler removes all generic type information, as they don’t know about generics.
Example
i.e. Map[Int, Int]
and Map[String, String]
are the same at runtime.
def isIntIntMap(x: Any) = x match {
case m: Map[Int, Int] => true
case _ => false
}
Result
//val res0: Boolean = true
isIntIntMap(Map(1 -> 1))
//val res1: Boolean = true
isIntIntMap(Map("abc" -> "abc"))
Warning
We would get Unchecked Warning
for #isIntIntMap
def isIntIntMap(x: Any): Boolean
-- Unchecked Warning:
2 | case m: Map[Int, Int] => true
| ^
| the type test for Map[Int, Int] cannot be checked at runtime
Exception
Exception – arrays, because they are handled specially in Java and Scala.
The element type of an array is stored with the array value to pattern match on it.
//-----------------------------------
// Exception
def isStringArray(x: Any) = x match {
case a: Array[String] => "yes"
case _ => "no"
}
//val res2: String = yes
isStringArray(Array("abc"))
//val res3: String = no
isStringArray(Array(1, 2, 3))
Decomposition
Issue to Solve
We are trying to find a general and convenient way to access heterogeneous data in a class hierarchy.
Example:
An interpreter for arithmetic expressions for numbers and additions.
Non-Solution 1: Classification and access methods
Expressions can be represented as a class hierarchy, with a base trait Expr
and two subclasses, Number
and Sum
.
The expression’s shape and its components:
trait Expr {
def isNumber: Boolean
def isSum: Boolean
def numValue: Int
def leftOp: Expr
def rightOp: Expr
}
class Number(n: Int) extends Expr {
def isNumber = true
def isSum = false
def numValue = n
def leftOp = throw Error("Number.leftOp")
def rightOp = throw Error("Number.rightOp")
}
class Sum(e1: Expr, e2: Expr) extends Expr {
def isNumber = false
def isSum = true
def numValue = throw Error("Sum.numValue")
def leftOp = e1
def rightOp = e2
}
Problem: Quadratic explosion**
-
Writing all these classification and accessor functions quickly becomes tedious
-
There’s no static guarantee you use the right accessor functions. You might hit an Error case if you
are not careful
Non-Solution 2: Type Tests and Type Casts
Note: Type tests and type casts are discouraged in Scala –
A “hacky” solution could use type tests and type casts. Scala lets you do these using methods defined in class Any:
// checks whether this object’s type conforms to ‘T‘
def isInstanceOf[T]: Boolean
// treats this object as an instance of type ‘T‘
def asInstanceOf[T]: T
// throws ‘ClassCastException‘ if it isn’t.
like
(1).isInstanceOf[Int] // true
(1).asInstanceOf[Double] // val res1: Double = 1.0
Problem: Unsafe, low-level
- Here’s a formulation of the eval method using type tests and casts
This is ugly and potentially unsafe.
// Non-solution
def eval(e: Expr): Int = {
if e.isInstanceOf[Number] then
e.asInstanceOf[Number].numValue
else if e.isInstanceOf[Sum] then
eval(e.asInstanceOf[Sum].leftOp)
+ eval(e.asInstanceOf[Sum].rightOp)
else throw Error(s"Unknown expression $e")
}
Non-Solution 3: Object-Oriented Decomposition
Example
Evaluate expressions:
trait 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
}
Limitation of OO decomposition
OO decomposition only works well if operations are on a single object.
Example: To simplify expressions, say using the rule:
a * b + a * c -> a * (b + c)
i.e. This is a non-local simplification. It cannot be encapsulated in the method of a single object.
Problem:
If we want a new method, we must define new methods in all the subclasses.
-
OO decomposition mixes data with operations on the data.
-
This can be the right thing if there’s a need for encapsulation and data abstraction.
-
On the other hand, it increases complexity and adds new dependencies to classes.
-
It makes it easy to add new kinds of data but hard to add new kinds of operations.
Also, it causes coupling between data and operations, need to touch all classes to add a new method.
Summary
Problem to solve:
Find a general and convenient way to access heterogeneous data in a class hierarchy.
| Attempts | Issue |
|————————————|———————————————————————————————-|
| Classification and access methods | Quadratic explosion |
| Type tests and casts | Unsafe, low-level |
| Object-oriented decomposition | Causes coupling between data and operations. Need to touch all classes to add a new method. |
Intro to Pattern Matching
Pattern matching is a mechanism for checking a value against a pattern.
A successful match can also deconstruct a value into its constituent parts.
i.e. Pattern matching is a generalization of switching from C/Java to class
hierarchies
Problem Solving
Observation: the sole purpose of test and accessor functions is to reverse
the construction process:
-
Which subclass was used?
-
What were the arguments of the constructor?
This situation is so common that many functional languages, Scala included, automate it.
Functional Decomposition with Pattern Matching
A case class definition is similar to a normal class definition, except it is preceded by the modifier case. For example:
trait Expr
case class Number(n: Int) extends Expr
case class Sum(e1: Expr, e2: Expr) extends Expr
Like before, this defines a trait Expr, and two concrete subclasses Number and Sum.
A match expression has a value, the match
keyword, and at least one case
clause.
def eval(e: Expr): Int = e match
case Number(n) => n
case Sum(e1, e2) => eval(e1) + eval(e2)
Rules
- The matching is preceded by a selector expression and is followed by a
sequence of cases, pat => expr
.
-
Each case associates an expression expr with a pattern pat.
-
A
MatchError
exception is thrown if no pattern matches the value of
the selector.
Evaluating Match Expressions
An expression of the form
e match { case p1 => e1 ... case pn => en }
- Matches the value of the selector e with the patterns
p1, ..., pn
in the order in which they are written. - The match expression is rewritten to the right-hand side of the first case where the pattern matches the selector
- References to pattern variables are replaced by the corresponding parts in the selector.
What Do Patterns Match?
- A constructor pattern
C(p1, ..., pn)
matches all the values of type C (or a subtype) that have been constructed with arguments matching the
patterns p1, ..., pn.
-
A variable pattern x matches any value, and binds the variable’s name to this value.
-
A constant pattern c matches values that are equal to c (in the sense
of ==)
Pattern Matching vs Java Switch
Pattern Matching is a more powerful version of the switch statement in Java. It can likewise be used in place of a series of if/else statements.
Java’s switch is a fall-through statement, which means it executes all the statements after the very first match until it confronts a break statement. In Scala, there’s no break statement. Also, there’s no default case in Scala’s pattern matching. Instead, a wildcard "_" is used that matches against any other case that has not been covered in previous case statements.
image source: https://subscription.packtpub.com/book/application_development/9781788392822/3/ch03lvl1sec28/pattern-matching
Pattern Matching with Examples
Matching on case classes
Case classes are especially useful for pattern matching.
abstract class Notification
case class Email(sender: String, title: String, body: String) extends Notification
case class SMS(caller: String, message: String) extends Notification
case class VoiceRecording(contactName: String, link: String) extends Notification
Notification
is an abstract super class that has three concrete Notification types implemented with case classes Email
, SMS
, and VoiceRecording
. Now we can do pattern matching on these case classes:
def showNotification(notification: Notification): String = {
notification match {
case Email(sender, title, _) =>
s"You got an email from $sender with title: $title"
case SMS(number, message) =>
s"You got an SMS from $number! Message: $message"
case VoiceRecording(name, link) =>
s"You received a Voice Recording from $name! Click the link to hear it: $link"
}
}
val someSms = SMS("12345", "Are you there?")
val someVoiceRecording = VoiceRecording("Tom", "voicerecording.org/id/123")
showNotification(someSms) // prints You got an SMS from 12345! // Message: Are you there?
showNotification(someVoiceRecording) // prints You received a Voice Recording from Tom! Click the link to hear it: voicerecording.org/id/123
The function showNotification
takes as a parameter the abstract type Notification
and matches the type of Notification
(i.e. it figures out whether it’s an Email
, SMS
, or VoiceRecording
). In the case Email(sender, title, _)
, the fields sender
and title
are used in the return value, but the body
field is ignored with _
.
Pattern Guards
Pattern guards are simply boolean expressions used to make cases more specific. Just add if <boolean expression>
after the pattern.
import week4.*
import week4.Notification.showNotification
def showImportantNotification(notification: Notification, importantPeopleInfo: Seq[String]): String = {
notification match {
case Email(sender, _, _) if importantPeopleInfo.contains(sender) =>
"You got an email from special someone!"
case SMS(number, _) if importantPeopleInfo.contains(number) =>
"You got an SMS from special someone!"
case other =>
showNotification(other) // nothing special, delegate to our original showNotification function
}
}
val importantPeopleInfo = Seq("867-5309", "[email protected]")
val someSms = SMS("123-4567", "Are you there?")
val someVoiceRecording = VoiceRecording("Tom", "voicerecording.org/id/123")
val importantEmail = Email("[email protected]", "Drinks tonight?", "I'm free after 5!")
val importantSms = SMS("867-5309", "I'm here! Where are you?")
showImportantNotification(someSms, importantPeopleInfo) // prints You got an SMS from 123-4567! Message: Are you there?
showImportantNotification(someVoiceRecording, importantPeopleInfo) // prints You received a Voice Recording from Tom! Click the link to hear it: voicerecording.org/id/123
showImportantNotification(importantEmail, importantPeopleInfo) // prints You got an email from special someone!
showImportantNotification(importantSms, importantPeopleInfo) // prints You got an SMS from special someone!
In the case Email(sender, _, _) if importantPeopleInfo.contains(sender)
, the pattern is matched only if the sender
is in the list of important people.
Matching Types
Can match on the type like so:
abstract class Device
case class Phone(model: String) extends Device {
def screenOff = "Turning screen off"
}
case class Computer(model: String) extends Device {
def screenSaverOn = "Turning screen saver on..."
}
def goIdle(device: Device) = device match {
case p: Phone => p.screenOff
case c: Computer => c.screenSaverOn
}
goIdle(Computer("Alienware"))
// val res0: String = Turning screen saver on...
def goIdle
has different behaviour depending on the type of Device
. This is useful when the case needs to call a method on the pattern. It is a convention to use the first letter of the type as the case identifier (p
and c
in this case).
Match Sealed classes
Traits and classes can be marked sealed
, which means all subtypes must be declared in the same file. This assures that all subtypes are known.
sealed abstract class Furniture
case class Couch() extends Furniture
case class Chair() extends Furniture
def findPlaceToSit(piece: Furniture): String = piece match {
case a: Couch => "Lie on the couch"
case b: Chair => "Sit on the chair"
}
findPlaceToSit(Chair())
// val res0: String = Sit on the chair
Summary of Forms of Patterns
Patterns are constructed from:
-
constructors, e.g. Number, Sum,
-
variables, e.g. n, e1, e2,
-
wildcard patterns _,
-
constants, e.g. 1, true.
-
type tests, e.g. n: Number
Variables always begin with a lowercase letter.
The same variable name can only appear once in a pattern. So, Sum(x, x) is not a legal pattern.
Names of constants begin with a capital letter, except the reserved words null, true, false.
Cannot Match …
Scala cannot match constructors of regular classes.
class A(a: Int)
A(3) match
// A cannot be used as an extractor in a pattern
// because it lacks an unapply or unapplySeq method
//-- Error: case A(a) => a
case _ => -1
Using the constructor of a regular class in a pattern position results in a compilation error:
A cannot be used as an extractor in a pattern because it lacks an unapply or unapplySeq method.