Fundamental
Primitive Types
Primitive types in Scala are like the ones in Java, but they are capitalized
- Int: 32-bit integers
- Long: 32-bit integers
- Float: 32-bit floating point numbers
- Double: 64-bit floating point numbers
- Char: 16-bit unicode characters
- Short: 16-bit integers
- Byte: 8-bit integers
- Boolean: boolean values
trueandfalse
scala> val x = 23
val x: Int = 23
Expression
Scala supports functional programming quite well. Many items are expressions in Scala, though in many other languages they are not treated as expressions. For example the if...then...else expression
val x = if true then 10 else 20
A block is denoted by a pair of curly braces {}. A block is an expression, and it uses the last expression as its value.
val x = {
val y = 10
y + 5
}
// the block is evaluated to 15, so x is 15
In Scala 3, braces are optional, so below code snippet is valid.
scala> val x2 =
| val y2 = 20
| y2 * 3
|
val x2: Int = 60
To declare a variable, use var. However, following functional programming style, mutation is encouraged, and use val in immutation case.
val x = 1
val y: Int = 2
var sum = 0
Evaluation
To evaluate an expression
- Take the leftmost operator.
- Evaluate its operands, left to right for multiple operands case.
- Apply the operator to the operands.
There are two evaluation strategies regarding function invocation
- Applicative order(call by value): reduce function arguments to values before rewriting function application
- by default used in Scala
- the expression evaluates at most once
- Normal order(call by name): apply function directly without reducing arguments first
- use
=>to indicate normal order before parameters in function signature - no need to do unnecessary evaluation
- use
The two evaluation strategies reduce to the same final result as long as
- The reduce expression consists of pure function.
- Both evaluations terminate.
Below is an example showing how ‘impure’ function evaluates to different results in applicative order and normal order.
import scala.util.Random
def squareA(x: Int): Int = x * x
def squareN(x: => Int): Int = x * x
@main
def main = {
val rnd = new Random()
val resultA = squareA(rnd.nextInt(10))
println(s"Got a square(A) value: $resultA")
// below evaluates to `rnd.nextInt(10) * rnd.nextInt(10)`
val resultN = squareN(rnd.nextInt(10))
println(s"Got a square(N) value: $resultN")
}
Normal order evaluation does NOT do unnecessary evaluation.
// define infinite loop
def loop: Boolean = loop
// define normal order function
def constOne(x: Int, y: => Boolean) = 1
// y follows normal order evaluation, so it is NOT evaluated at all.
constOne(2, loop) // won't trigger infinite loop
Scala uses short-circuit evaluation for && and ||. As a result, true || loop terminates.
Recursion
Recursion is widely used in FP, but it prevents the compiler from inferring the function’s return type, requiring an explicit type declaration in the function signature.
def fact(n: Int): Int =
require(n >= 0, "parameter should NOT be negative")
if n == 0 then 1 else n * fact(n-1)
Tail Recursion
Tail recursion is supported in Scala. The @tailrec annotation is more like a reminder, because even if the annotation is not present, the Scala compiler will still try to make recursion tail-recursive whenever possible
def fact(n: Int): Int =
require(n >= 0, "parameter should NOT be negative")
@tailrec
def factTR(curr: Int, accu: Int): Int =
if curr == 0 then accu else factTR(curr-1, accu*curr)
factTR(n, 1)
Higher Order Function
Functions are treated as first-class citizens in Scala. That means functions can be parameters and return values of functions. Functions that take other functions as parameters or return functions as return values are called higher order functions(HOF).
But why does that matter? HOF provides a flexible way to compose functions.
def sumOfSquare(lowerBound: Int, upperBound: Int): Int =
require(lowerBound <= upperBound, "Lower bound should NOT exceed upper bound.")
if lowerBound == upperBound then lowerBound*lowerBound else lowerBound*lowerBound + sumOfSquare(lowerBound+1, upperBound)
def sumOfDouble(lowerBound: Int, upperBound: Int): Int =
require(lowerBound <= upperBound, "Lower bound should NOT exceed upper bound.")
if lowerBound == upperBound then upperBound*2 else lowerBound*2 + sumOfDouble(lowerBound+1, upperBound)
The behavior of above two functions is very similar to each other. We can ’extract’ the different part, and introduce a function to ‘abstract’ the behavior like below
def sumOfFn(lowerBound: Int, upperBound: Int, fn: Int => Int): Int =
require(lowerBound <= upperBound, "Lower bound should NOT exceed upper bound.")
if lowerBound == upperBound then fn(lowerBound) else fn(lowerBound) + sumOfFn(lowerBound+1, upperBound, fn)
# to get sum of square from 3 to 6
sumOfFn(3, 6, x => x*x)
There is still hard code in above implementation, the + operation. If we extract that part, and introduce a function, we actually implement the map-reduce operation.
def mapReduce(lowerBound: Int, upperBound: Int, map: Int => Int, reduce: (Int, Int) => Int): Int =
require(lowerBound <= upperBound, "Lower bound should NOT exceed upper bound.")
if lowerBound == upperBound then map(lowerBound)
else reduce(map(lowerBound), mapReduce(lowerBound+1, upperBound, map, reduce))
# sum of square from 3 to 6
mapReduce(3, 6, x => x*x, (a, b) => a+b)
Anonymous Functions
In above sumOfFn and mapReduce cases, anonymous functions are passed as arguments to the functions.
val doubleFn = (x: Int) => x * 2
Note
- To explicitly declare the return type of function like
(x: Int): Int => x*2is NOT legal. - Usually the return type of the function can be inferred, but if we’d like to explicitly denote the return type, we declare it on the identifier like
val cube: Int => Int = x => x*x*x
Function Currying
When invoking above mapreduce function, we need to provide all four parameters. Think about below cases
- sum of square from 3 to 5
- sum of double from 2 to 6
- sum of cube from 5 to 7
In above cases, the reduce function is sum, and we need to repeatedly provide it in each invocation. It would be better if we can make an ‘intermediate’ function that is fixed to sum as reduce logic. To do that we utilize function currying.
// mapReduce via function currying
def mapReduce(reduce: (Int, Int) => Int) (map: Int => Int) (lowerBound: Int, upperBound: Int): Int =
require(lowerBound <= upperBound, "Lower bound should NOT exceed upper bound.")
if lowerBound == upperBound then map(lowerBound)
else reduce(map(lowerBound), mapReduce(reduce)(map)(lowerBound+1, upperBound))
// As usual we can provide all the parameters
mapReduce((a: Int, b: Int)=>a+b) ((x:Int)=>x*x) (3,5)
The mapReduce function has type ((Int, Int) => Int) => (Int => Int) => (Int, Int) => Int, which forms a chain of functions.
scala> mapReduce
val res0: ((Int, Int) => Int) => (Int => Int) => (Int, Int) => Int = Lambda/0x0000026581544c10@5b74902c
That makes partial application available. Partial application returns an ‘intermediate’ function. Note
- Function types associate to the right, so
Int => Int => IntmeansInt => (Int => Int). - Function invocation assoticates to the left, so
myFun(1)(2)means(myFun(1))(2).
// partial application with `reduce` function provided
scala> val sumOfFn = mapReduce((a: Int, b: Int) => a+b)
val sumOfFn: (Int => Int) => (Int, Int) => Int = Lambda/0x0000026581570410@1397b141
scala> sumOfFn((x: Int) => x*x)(3,5)
val res1: Int = 50
scala> sumOfFn((x: Int) => x*x*x)(3,5)
val res2: Int = 216
// further partial applicaiton with `map` function provided
scala> val sumOfQuad = sumOfFn((x: Int) => x*4)
val sumOfQuad: (Int, Int) => Int = Lambda/0x0000026581572000@2bc8caa7
scala> sumOfQuad(3, 6)
val res3: Int = 72
scala> sumOfQuad(5, 9)
val res4: Int = 140
Object Oriented Programming with Scala
Scala can be used in functional programming style, but it also supports object oriented programming well.
Simple Introduction to Classes
To define a class
class Robot(val robotName: String, height: Int):
val robotHeight = height
def introSelf(target: Robot) : String =
s"Hello ${target.robotName}, I am ${this.robotName}"
@main
def main =
val arale = new Robot("Arale", 139)
val wallE = new Robot("wallE", 100)
println(arale.introSelf(wallE))
println(s"WallE's height is ${wallE.robotHeight}")
Note
- Don’t forget the colon
:at the end of the ‘class definition’ line. val robotName: StringmakesrobotNamea public field and available from outside of the class.- Without
valin front ofrobotName,robotNamewould not be treated as a filed, it’s only visible inside of the class, andtarget.robotNamewould cause syntax error.
- Without
robotHeightis defined as a value, which represents one attribute of the class.thiskeyword represents the current object of the class.
Data Abstraction with Classes
Classes can be used to describe data and behavior. For example, below class represents a rational number
// define a class without explicit class body
class Rational(val numerator: Int, val denominator: Int)
// try it in Scala REPL
scala> val r1 = Rational(15, 55)
val r1: Rational = Rational@a4dcede
scala> r1.numerator
val res10: Int = 15
scala> r1.denominator
val res11: Int = 55
Explanation
valbefore the class parameters (numeratoranddenominator) make those parameters available to the outside of the class. Otherwise, they can only be visited inside of the class.
Reduction of fraction is a common operation to rational numbers.
class Rational(val numerator: Int, val denominator: Int):
// greatest common divisor
private def gcd(a: Int, b: Int): Int =
if b == 0 then a else gcd(b, a%b)
def numer = numerator/gcd(numerator, denominator)
def denom = denominator/gcd(numerator, denominator)
override def toString = s"$numerator/$denominator[$numer/$denom]"
// try in REPL
scala> val r1 = Rational(15, 55)
val r1: Rational = 15/55[3/11]
In above implementation, gcd is repeatedly called each time numer or denom is called. To prevent that, refine the implementation as
// refine Rational implementation to remove duplicate call on gcd
class Rational(val numerator: Int, val denominator: Int):
private def gcd(a: Int, b: Int): Int =
if b == 0 then a else gcd(b, a%b)
private val divisor = gcd(numerator, denominator)
def numer = numerator/divisor
def denom = denominator/divisor
override def toString = s"$numerator/$denominator[$numer/$denom]"
// try in REPL
scala> val r2 = Rational(27, 90)
val r2: Rational = 27/90[3/10]
From the caller perspective, it may not realize the refinement at all, because the way how it interacts with Rational remains the same. The ability to choose different implementations of the data without affecting clients is called data abstraction.
Extension Methods
Extension methods allow use toe expand the behavior of a class. Some typical cases are
- Operations that feel like they “should” be part of a type but aren’t.
- Domain-specific operations on thrid-party or standard library types, when we cannot modify the original class but want to add domain-specific behavior.
For example, in above Rational case, suppose Rational class is defined as a standard library class. We cannot modify the “original” standard libraray code, but we can utilize extention methods to implement operations like abs, min and etc., so that we “expand” the feature of Rational.
class Rational(val numerator: Int, val denominator: Int):
private def gcd(a: Int, b: Int): Int =
if b == 0 then a else gcd(b, a%b)
private val divisor = gcd(numerator, denominator)
def numer = numerator/divisor
def denom = denominator/divisor
override def toString = s"[$numer/$denom]"
extension (r: Rational)
def abs: Rational = Rational(r.numer.abs, r.denom.abs)
end extension
// run in REPL
scala> val myNumber = new Rational(-2, 5)
val myNumber: Rational = [-2/5]
scala> myNumber.abs
val res0: Rational = [2/5]
Note
- Unlike
class, there is no trailing colon onextensionline. rinextension(r: Rational)is known as extension parameter.- Define parameterless function
abswithout a pair of empty parentheses to indicate no side effect. - Extentions can only add new members, not override existing ones.
Different people may define extensions in different modules so that they can have their own versions of enhancement that don’t run into others. Extensions of a class become available if they are
- defined or imported in the current scope
- listed in the companion object
Infix Notation
Operators like +, - and etc. are identifiers, and they can be used as function names.
class Rational(val numerator: Int, val denominator: Int):
private def gcd(a: Int, b: Int): Int =
if b == 0 then a else gcd(b, a%b)
private val divisor = gcd(numerator, denominator)
def numer = numerator/divisor
def denom = denominator/divisor
override def toString = s"[$numer/$denom]"
def +(o: Rational): Rational =
new Rational(numer*o.denom+denom*o.numer, denom*o.denom)
extension (r: Rational)
def abs: Rational = Rational(r.numer.abs, r.denom.abs)
def -(o: Rational) =
new Rational(r.numer*o.denom-r.denom*o.numer, r.denom*o.denom)
end extension
With above + method and - method defined, they can be used as infix notation
// run in REPL
scala> val r1 = new Rational(2,3)
val r1: Rational = [2/3]
scala> val r2 = new Rational(1,5)
val r2: Rational = [1/5]
scala> r1 + r2
val res0: Rational = [13/15]
scala> r1 - r2
val res1: Rational = [7/15]
Explanation
- Those infix notation functions can be defined in either classes or extensions.
- If the identifier is not symbolic, such as
min, the leadinginfexmodifer is mandatory.
extension (r: Rational)
def abs: Rational = Rational(r.numer.abs, r.denom.abs)
def -(o: Rational) =
new Rational(r.numer*o.denom-r.denom*o.numer, r.denom*o.denom)
infix def min(o: Rational) =
if r.numer * o.denom <= o.numer * r. denom
then r
else o
end extension
Abstraction and Class Hierarchies
Programming to abstractions rather than concrete implementations is a cornerstone principle behind most design patterns. In Scala, we can define abstract classes to follow that principle. Suppose we need a type to represent a set of integer numbers. We can have below abstraction.
abstract class IntSet:
def include(x: Int): IntSet
def contains(x: Int): Boolean
One implementation for above IntSet is binary trees. For instance, if we have the IntSet {1, 2, 4, 5}, below is the corresponding binary tree

Obviously two types of nodes are needed.
- Empty node
- NonEmpty node
class Empty() extends IntSet:
def include(x :Int): IntSet = NonEmpty(x, Empty(), Empty())
def contains(x: Int): Boolean = false
override def toString = "<E>"
class NonEmpty(elem:Int, left: IntSet, right: IntSet) extends IntSet:
def include(x: Int): IntSet =
if x > elem then NonEmpty(elem, left, right.include(x))
else if x < elem then NonEmpty(elem, left.include(x), right)
else this
def contains(x: Int): Boolean =
if x > elem then right.contains(x)
else if x < elem then left.contains(x)
else true
override def toString = s"($elem, ${left.toString}, ${right.toString})"
Explanation
extendskeyword denotes the subclass-superclass relationship.- A class defined without
extendsactually extendsObjctclass, which isjava.lang.Object. - The direct or indirect superclass of a class is called base class. For instance,
IntSetandObjectare base classes ofEmptyclass.
- A class defined without
overrideis a modifer, instead of an annotation in Scala. It denotes to override the exsiting method defined in base class.
// run in REPL
scala> val intSetA = Empty().include(1).include(4).include(5).include(2)
val intSetA: IntSet = (1, <E>, (4, (2, <E>, <E>), (5, <E>, <E>)))
scala> val intSetB = Empty().include(4).include(1).include(5).include(2)
val intSetB: IntSet = (4, (1, <E>, (2, <E>, <E>)), (5, <E>, <E>))
object for Singleton
In above IntSet implementation, Empty is defined as a class, meaning multiple instances of Empty will be created, and that’s a waste. To fix that defect, use object type to implement singleton.
object Empty extends IntSet:
def include(x :Int): IntSet = NonEmpty(x, Empty, Empty)
def contains(x: Int): Boolean = false
override def toString = "<E>"
end Empty // optional end marker
Note
Emptynow is object type and we cannot create instance of it anymore. To reference it, simply useEmpty.- Singleton objects are values, so
Emptyevaluates to itself.
Companion object
When a class type and an object type are defined in the same source file with the same name, they are called companions. Note
- The companion object must be defined in the same source file as its companion class.
- If they are defined in separate files, they are just a regular class and a regular objct that happen to have the same name.
A companion object of a class plays a role similar to static class definitions in Java.
object for Program Entry Point
To build a standalone application/program, create an object type with main method
object Hello:
def main(args: Array[String]): Unit =
println("Hello from Scala!")
A common best practice is to name the source file after the object it defines, so name the source file Hello.scala
To execute the program
C:\sandbox\ScalaLab\Scripts> scala run Hello.scala
Compiling project (Scala 3.7.4, JVM (21))
Compiled project (Scala 3.7.4, JVM (21))
Hello from Scala!
Alternatively, we can compile the source to class files using scala compile, and then run the program. Notice in Scala 3, powered by Scala CLI, a unifed scala command with subcommands like compile, run, repl, test, etc. are introduced. The old scalac and scala still exist for backward compatibility.
Using scala compile, class files are generated under .scala-build/ directory by default.
Dynamic Method Binding
Method invocation depends on the runtime type of the object.
class Robot(val name: String):
def greet(target: String): String =
s"Hi $target, this is Robot[$name]."
class CleaningRobot(name: String) extends Robot(name):
override def greet(target: String): String =
s"Greeting from CleaningRobot[$name]: Hello $target."
@main
def main() =
val wallE: Robot = new CleaningRobot("Wall-E")
println(wallE.greet("EVE"))
Explanation
wallEis declared asRobottype, but assigned to an instance ofCleaningRobot.- The method invocation depends on the runtime type, so the
greetdefined inCleaningRobotis invoked.
Organize Classes with Packages
Classes and objects are organized in packages. To place a class/object type inside a package, use package keyword at the top of the source file. For example
package id.my.cxu.examples
object Hello:
def main(args: Array[String]): Unit =
println("Hello from Scala!")
def sayHello(target: String) = s"Hello, $target"
Now compile the source file.
C:\sandbox\ScalaLab\Scripts> scala compile Hello.scala
Compiling project (Scala 3.7.4, JVM (21))
Compiled project (Scala 3.7.4, JVM (21))
C:\sandbox\ScalaLab\Scripts> fd -H .*Hello
.scala-build/.bloop/Scripts_86c27f223b-f4d7975d63/bloop-internal-classes/main-3F_GeVW0Sx2WzkmaZblELw==/id/my/cxu/examples/Hello$.class
.scala-build/.bloop/Scripts_86c27f223b-f4d7975d63/bloop-internal-classes/main-3F_GeVW0Sx2WzkmaZblELw==/id/my/cxu/examples/Hello.class
.scala-build/.bloop/Scripts_86c27f223b-f4d7975d63/bloop-internal-classes/main-3F_GeVW0Sx2WzkmaZblELw==/id/my/cxu/examples/Hello.tasty
.scala-build/Scripts_86c27f223b-f4d7975d63/classes/main/id/my/cxu/examples/Hello$.class
.scala-build/Scripts_86c27f223b-f4d7975d63/classes/main/id/my/cxu/examples/Hello.class
.scala-build/Scripts_86c27f223b-f4d7975d63/classes/main/id/my/cxu/examples/Hello.tasty
Hello.scala
Explanation
- One common best practice is to construct the same directory structure for srouce file. So in above case, the
Hello.scalais supposed to be put underid/my/cxu/examples, but that’s not mandatory. - In above example, the ‘qualified name’ of the
Helloisid.my.cxu.examples.Hello. The compilation operation generates the directories according to the package.
To reference to the type, use the qualified name including both package name and class name.
@main
def main() =
val greeting = id.my.cxu.examples.Hello.sayHello("Wall-E")
println(greeting)
Execute above script file (namely lab.scala) with -cp to indicate the path of the dependent class file.
C:\sandbox\ScalaLab\Scripts> scala run -cp .scala-build\Scripts_86c27f223b-f4d7975d63\classes\main lab.scala
Compiling project (Scala 3.7.4, JVM (21))
Compiled project (Scala 3.7.4, JVM (21))
Hello, Wall-E
Arguably, a better way to run the script is to provide both the entry point and the dependent files together. However, in above case, there are two ’entry points’. To prove that point, use the command scala run --main-class-ls <scala_files> to list the available entry points of the scala files.
C:\sandbox\ScalaLab\Scripts> scala run --main-class-ls Hello.scala lab.scala
Warning: setting C:\Sandbox\ScalaLab\Scripts as the project root directory for this run.
Compiling project (Scala 3.7.4, JVM (21))
Compiled project (Scala 3.7.4, JVM (21))
id.my.cxu.examples.Hello main
Explanation: the command returns two entry points
id.my.cxu.examples.Hello- Now we need to use the qualified name, instead of simply
Helloto refer to theHelloobject.
- Now we need to use the qualified name, instead of simply
main
The desired entry point in this case is main, so we execute the command scala run -M main Hello.scala lab.scala
C:\sandbox\ScalaLab\Scripts> scala run -M main Hello.scala lab.scala
Warning: setting C:\Sandbox\ScalaLab\Scripts as the project root directory for this run.
Hello, Wall-E
Explanation
-Moption denotes the ’entry point’/‘main class’ to run. In above script case, namely that’smain.
Import Classes
Using the qualified name of a class seems combersome, and we can ‘import’ the class to simplify the reference form. There are two types import
- named import, such as
import id.my.cxu.examples.Helloto just import theHelloobject - wildcard import, such as
import id.my.cxu.examples._to import everything in theid.my.cxu.examplespackage.- Notice the wildcard is underscore, NOT asterisk.
We can import types from either of the following:
- a package
- conceptually, a package is a ’large’ object that contains many members in several individual source files.
- an object
So refine above script file as
@main
def main() =
import id.my.cxu.examples.Hello
val greeting = Hello.sayHello("Arale")
println(greeting)
Notice some items are automatically imported in every Scala program.
- members of package
scala - members of package
java.lang - members of the singleton object
scala.Prefe
That’s the reason why some types and functions seem available ’natually’, for example
Int(defined inscala.Int)Boolean(defined inscala.Boolean)Object(defined injava.lang.Object)require(defined inscala.Predef.require)assert(defined inscala.Predef.assert)
Traits
A class may have several natural supertypes, and that is implemented with traits in Scala. A class can inherit from
- at most one class
- arbitrary many traits
A trait is declared like an abstract class
trait Movable:
def move(): Unit
trait Soundable:
def sound: String
class Robot extends Movable, Soundable:
def move() = println("Robot is moving")
def sound = "Robot is talking"
@main
def main =
val arale = new Robot()
arale.move()
println(arale.sound)
Explanation
- Class
Robotextends two traits in above example.
Class Hierarchy

Explanation
scala.AnyValis the super type of all the primitive types.scala.AnyRefis the synonym ofjava.lang.Object, and it’s the super type of the user defined class.- Root type of the type system is
scala.Any.- Methods like
==,!=,equals,hashCode,toStringare defined there.
- Methods like
- Bottom type of the type system is
scala.Nothing.Nothingis the subtype of every other type.Nothingis a type, and there is no value of typeNothing, so we cannot assignNothingto an identifier.- Use case of
Nothingtype: as an element type of empty collections.
- Notice two types of arrows
- Solid line arrows: conform to the super class
- Dashed line arrows: implicit conversion
A Tricky Type Question
Given below expression, what’s the type of it?
if true then 1 else false
In Scala 2, we get the common supertype of Int and Boolean, so we end up getting type AnyVal. However in Scala 3, we get Int | Boolean, the union type, which represents a value that can be either an Int or Boolean.
By design, the compiler does NOT look into the condition, although it’s even fixed as true.
Polymorphism
Polymorphism is one of the pillars of OOP. To help understand and remember the word
- poly: it means many
- morphism: it means forms
In programming, polymorphism means
- the identify of a certain instance can be viewed as many different types
- the function can be applied to arguments of many types
class Robot(val name: String, val model: String):
def move(): Unit = println("Robot is moving...")
class HumanoidRobot(name: String, model: String) extends Robot(name, model):
def talk(): Unit = println(s"HumanoidRobot[$name] is talking...")
class Arale(robotID: String) extends HumanoidRobot("Arale-"+robotID, "Arale"):
override def move(): Unit = println(s"$name is running...")
@main
def main() =
val a01: Robot = new Arale("a01")
a01.move()
val a02: HumanoidRobot = new Arale("a02")
a02.talk()
a02.move()
// running above program and we get
scala run PolyLab.scala
Compiling project (Scala 3.7.4, JVM (21))
Compiled project (Scala 3.7.4, JVM (21))
Arale-a01 is running...
HumanoidRobot[Arale-a02] is talking...
Arale-a02 is running...
Explanation
a01is declared as typeRobot, but in fact it is an instance ofArale. Due to dynammic method binding, the effective method fora01.move()invocation is the one defined inArale.a02is an instance ofArale, and it can be recognized as an instance ofHumanoindRobotas well, soa02.talk()works.
Generics
Suppose we need to implement the linked list data structure. Below is a simple design
Nilto represent the empty listConsto represent a cell containing an element, and the remainder (tail) of the list
For a list like List(1, 2, 3), above design looks like

The implementation can be
import java.{util => ju}
trait IntList:
def isEmpty: Boolean
def head: Int
def tail: IntList
class Cons(val head: Int, val tail: IntList) extends IntList:
def isEmpty: Boolean = false
override def toString = s"$head | ${tail.toString}"
object Nil extends IntList:
def isEmpty = true
def head = throw new ju.NoSuchElementException()
def tail = throw new ju.NoSuchElementException()
override def toString = "<Nil>"
@main
def main() =
val myList = Cons(1, Cons(2, Cons(3, Nil)))
println(myList)
// execute and we get
scala run IntList.scala
Compiling project (Scala 3.7.4, JVM (21))
Compiled project (Scala 3.7.4, JVM (21))
1 | 2 | 3 | <Nil>
Explanation
val head: Intandval tail: IntListofConsfulfill theheadandtailmethods defined inIntList.- To create multiple instances of
Nildoes NOT make a lot of sense, so define it as object type.
It’s easy to create types like BooleanList, StringList, etc. In fact, the only difference among those types is the type of the inner stored elements. To ‘generalize the type’, we can use Type Parameters.
import java.{util => ju}
trait MyList[+T]:
def isEmpty: Boolean
def head: T
def tail: MyList[T]
class Cons[T](val head: T, val tail: MyList[T]) extends MyList[T]:
def isEmpty: Boolean = false
override def toString = s"$head | ${tail.toString}"
object Nil extends MyList[Nothing]:
def isEmpty = true
def head = throw new ju.NoSuchElementException()
def tail = throw new ju.NoSuchElementException()
override def toString = "<Nil>"
@main
def main() =
val myNumList: MyList[Int] = Cons(1, Cons(2, Cons(3, Nil)))
println(myNumList)
val myStrList : MyList[String] = Cons("Hello", Cons("From", Cons("Scala", Nil)))
println(myStrList)
Explanation
- The plus sign in
MyList[+T]makesMyListcovariant, which means an instance ofMyList[DerivedType]can be considered as an instance ofMyList[BaseType]. object Nil extends MyList[Nothing]makesNilto be an sub-type ofMyList[Nothing], which in turn makesNilan sub-type of anyMyList[+T].- Technically,
object Nil extends MyListalso works, but explicitly denotingNothingmakes the type relationship crystal clear.
- Technically,
Objects for OOP
In pure object-oriented programming language, every value is an object. To understand the point, let’s follow this idea to create an ideal version of Boolean type and values.
abstract class IdealBoolean:
def ifThenElse[T](t: => T, e: => T): T
def &&(x: => IdealBoolean): IdealBoolean =
ifThenElse(x, IdealFalse)
def ||(x: => IdealBoolean): IdealBoolean =
ifThenElse(IdealTrue, x)
def unary_! : IdealBoolean =
ifThenElse(IdealFalse, IdealTrue)
def ==(x: => IdealBoolean): IdealBoolean =
ifThenElse(x, x.unary_!)
def !=(x: => IdealBoolean): IdealBoolean =
ifThenElse(x.unary_!, x)
object IdealTrue extends IdealBoolean:
def ifThenElse[T](t: => T, e: => T): T = t
object IdealFalse extends IdealBoolean:
def ifThenElse[T](t: => T, e: => T): T = e
Explanation
IdealBooleanis an abstract class defining the behavior of the type.- There are only two possible values for
IdealBooleantype,IdealTrueandIdealFalse. It doesn’t make a lot of sense to create different instances ofIdealTrueandIdealFalse, so they are defined asobject
Functions as Objects
In Scala, a function can be treated as an object. The function type A => B is just an abbreviation of the type scala.Function[A, B], just as below
package scala
trait Function1[A, B]:
def apply(x: A): B
Explanation
- Functions are objects with
applymethods. - The name of the trait in above example is
Function1, and there are traits named asFunction2,Function3, etc., for functions which have more parameters.
Let’s create equivalent integer sum functions using anonymous function and Function2 trait
@main
def main() =
val intSumFn: (Int, Int) => Int = (a, b) => a + b
val intSumFn2: Function2[Int, Int, Int] = new Function2[Int, Int, Int]:
def apply(a: Int, b: Int): Int = a + b
val cmpr = intSumFn(2,3) == intSumFn2(2,3)
println(cmpr) // true
println(intSumFn == intSumFn2) // false
Explanation
Funciton2is an trait, so it’s impossible to create a ‘direct’ instance. As a result, ‘anonymous class’ is used to create an ‘indirect’ instance ofFunction2.- Although logically
intSumFnandintSumFn2are the same thing, they are not equivalent.
With above concept in mind, we now have two ways to invoke a function
- regular function call such as
fn(args) - using
applylikefn.apply(args)
@main
def main() =
val intSumFn: (Int, Int) => Int = (a, b) => a + b
println(intSumFn(5, 91))
println(intSumFn.apply(5, 91))
def intSumFn3(a: Int, b:Int): Int = a + b
println(intSumFn3(5, 23))
println(intSumFn3.apply(5, 23))
Exaplantion
intSumFnis an anonymous function andintSumFn3is a regular function. We can use either regular function call or theapplyfunction of the function object.
OO Decomposition vs Pattern Matching
Suppose we need to build a dummy interpretor and for demo purpose, let’s consider below entities with integer nubmers for now
Expr: the abstract type to represent expressions, and for now only consider integer numbersNumber: to represent numbers, and for demo purpose only consider integer numbers for nowSum: to represent addition operation, and for now only consider integer numbers
OO Decomposition
One solution to build the interpretor is like below, via OO decomposition
trait Expr:
def eval: Int
class Number(n: Int) extends Expr:
def eval: Int = n
class Sum(left: Expr, right: Expr) extends Expr:
def eval: Int = left.eval + right.eval
@main
def main() =
val e1 = Number(2)
val e2 = Sum(Number(2), Number(5))
val r = Sum(e1, e2).eval
print(r)
Explanation
Expris defined as a trait and it is abstract type.Numbercontains both data(n) and operation(eval).- Similar to
Number,Sumcontains both data(leftandright) and operation(eval)
But why is it called OO decomposition, given the fact that data is not separated from opertions?
- “Decomposition” here means to decompose a complex system into cohesive units with clear responsibilities. So in OO territory, it’s the idea of breaking a system down into objects, each representing a concept in the problem domain. An object encapsulates both state(data) and behavior(operations).
Above implementation decompose the problem domain into Expr trait, Number class and Sum class. The responsiblity is clear, so to some extent that’s a good design. Suppose we need to add new entities/data such as Prod to represent multiplication, it’s still quite easy.
class Prod(left: Expr, right: Expr) extends Expr:
def eval: Int = left.eval * right.eval
However if we need to add a new behavior, such as a display method, now that’s a problem, because every type needs to be updated.
trait Expr:
def eval: Int
def display: String
class Number(n: Int) extends Expr:
def eval: Int = n
def display: String = n.toString
class Sum(left: Expr, right: Expr) extends Expr:
def eval: Int = left.eval + right.eval
def display: String = s"${left.display} + ${right.display}"
@main
def main() =
val e1 = new Number(2)
val e2 = new Sum(new Number(2), new Number(5))
val e = new Sum(e1, e2)
print(s"${e.display} = ${e.eval}")
Note: using OO decomposition, it’s
- easy to add new kinds of entities/data
- hard to add new operations
FP Decomposition - Pattern Matching
Another approach to implement the interpretor is to utilize pattern matching.
trait Expr
case class Number(n: Int) extends Expr
case class Sum(left: Expr, right: Expr) extends Expr
def eval(expr: Expr): Int = expr match
case Number(n: Int) => n
case Sum(left: Expr, right: Expr) => eval(left) + eval(right)
@main
def main() =
val e1 = Number(91)
val e2 = Sum(Number(2), Number(3))
val e = Sum(e1, e2)
println(s"${eval(e)}")
Explanation
- We use case classes to represent numbers and operations, because classes have below ‘automatic’ features
- immutability by default: all constuctor parameters are automatically
valfields (public and immutable). - proper
equals()andhashCode(): automatically get propereuqals()andhashCode()to compare based on field values, not object identity. - pattern matching support: can be used in pattern matching with automatically generated
unapplymethod. - companion object: automatically get a companion object with an
applymethod, sonewkeyword is not needed in instantiation. copymethod: automatically get acopy()method that creates modified copies while keeping other fields the same to support working with immutable data.toStringimplementation: automatically get a readabletoStringimplementation showing class name and field values.
- immutability by default: all constuctor parameters are automatically
- Pattern matching in above example
matchis preceded by a selector expression, in above caseexpr.- A
MatchErroris thrown if no pattern matches the value of the selector expression. - Patterns are constructured from
- constructors, e.g.
Number(n) - variables, e.g.
n,left - wildcard patterns, e.g.
_to match anything but don’t care about the value - constants, e.g.
1,true - type test, e.g.
n: Int
- constructors, e.g.
- some ruels and conventions of patterns
- Variables always begin with a lowercase letter.
- Names of constants begin with a Capital letter, with the exception of the reserved words like
null,true,false. - The same variable name can only appear once in a pattern, so
Sum(x, x)is NOT a valid pattern.
Now think about below bullets
- Move
evalfunction intoExprtrait to from aevalmethod. - What if we’d like to add a
displaybehavior? - What if we’d like to add a
Prodcalculation?
trait Expr:
def eval: Int = this match
case Number(n: Int) => n
case Sum(left: Expr, right: Expr) => left.eval + right.eval
def display: String = this match
case Number(n) => n.toString
case Sum(l, r) => s"${l.display} + ${r.display}"
case class Number(n: Int) extends Expr
case class Sum(left: Expr, right: Expr) extends Expr
@main
def main() =
val e1 = Number(91)
val e2 = Sum(Number(2), Number(3))
val e = Sum(e1, e2)
println(s"${e.display} = ${e.eval}")
Explanation
evalis transformed from a function to a method ofExprtrait. Instead of accepting theexprparameter for pattern matching, it use the current instance, namelythis, directly.displayis added toExprtrait as another method. It also utilizes pattern matching technique.
It’s not that difficult to implement the first two bullets. However, to add additional data/entities, in this case Prod, all the pattern matching logic needs to be updated to ‘include’ that new data.
trait Expr:
def eval: Int = this match
case Number(n: Int) => n
case Sum(left: Expr, right: Expr) => left.eval + right.eval
case Prod(left: Expr, right: Expr) => left.eval * right.eval
def display: String = this match
case Number(n) => n.toString
case Sum(l, r) => s"${l.display} + ${r.display}"
case Prod(l:Sum, r:Sum) => s"(${l.display}) * (${r.display})"
case Prod(l, r:Sum) => s"${l.display} * (${r.display})"
case Prod(l:Sum, r) => s"(${l.display}) * ${r.display}"
case Prod(l, r) => s"${l.display} * ${r.display}"
case class Number(n: Int) extends Expr
case class Sum(left: Expr, right: Expr) extends Expr
case class Prod(left: Expr, right: Expr) extends Expr
@main
def main() =
val e1 = Number(91)
val e2 = Sum(Number(2), Number(3))
val e = Prod(e1, e2)
println(s"${e.display} = ${e.eval}")
Note: using FP decomposition, pattern matching, it’s
- easy to add new operations
- hard to add new kinds of entities/data
Back to the implementation. The 4 adjacent Prod patterns look a little awkard. To improve the implementation, we can do a further pattern matching like below
trait Expr:
def eval: Int = this match
case Number(n: Int) => n
case Sum(left: Expr, right: Expr) => left.eval + right.eval
case Prod(left: Expr, right: Expr) => left.eval * right.eval
def display: String = this match
case Number(n) => n.toString
case Sum(l, r) => s"${l.display} + ${r.display}"
case Prod(l, r) => s"${{addParenthesesIfNecessary(l)}} * ${{addParenthesesIfNecessary(r)}}"
case class Number(n: Int) extends Expr
case class Sum(left: Expr, right: Expr) extends Expr
case class Prod(left: Expr, right: Expr) extends Expr
def addParenthesesIfNecessary(e: Expr): String = e match
case Sum(l, r) => s"(${l.display} + ${r.display})"
case _ => e.display
@main
def main() =
val e1 = Number(91)
val e2 = Sum(Number(2), Number(3))
val e = Prod(e2, e1)
println(s"${e.display} = ${e.eval}")
Expalnation
addParenthesesIfNecessaryadds parentheses toSumexpression._pattern matches to any value.
Types to Support Pattern Matching
A list looks like an array, but there are two important fundamental differences
- Lists are immutable
- Lists are recursive
It’s easy to understand ‘immutable’ mentioned above, but what does ‘recursive’ mean? A non-empty list consists of an element and a sub list. The cons operator : separates the head(element) and the tail(sub list) of a list. For example

@main
def main() =
val l1 = List("US", "CN", "RU")
val l2 = "US" :: "CN" :: "RU" :: Nil
println(l1 == l2) // true
println(l1 eq l2) // false
println(Nil == List()) //true
Explanation
l1andl2are two equivalent lists, but they are indeed two different objects.==tests the value equality andeqtests the reference equality.Nilis simply an empty list. When using::to construct lists, do not forget to putNilat the end of the list to indicate there is no more sub list.
Below are the three commonly used methods of lists
head: to get the first element of the listtail: to get the sub listisEmpty: to return true if the list is empty, and otherwise false
List can be effectively used in pattern matching with understanding the underlying structure. Below are some examples
1 :: 2 :: xs: the list starting with element 1 and 2x: Nil: the list with one elementList(x): same as abovex :: NilList(): the empty list, same asNilList(2 :: xs): the list containing another list as its element, and the inner list starts with element 2
Utilize pattern matching to implement the insertion sort of lists
def insertionSort(l: List[Int]): List[Int] = l match
case Nil => Nil
case h :: Nil => l
case h :: t =>
val sortedTail = insertionSort(t)
if h > sortedTail.head then sortedTail.head :: insertionSort(h :: sortedTail.tail)
else h :: sortedTail
@main
def main() =
val l = List(5, 8, 2, 9, 7, 3)
val sortedL = insertionSort(l)
println(sortedL)
Exaplanation
insertionSortis a recursive function so the return type should be declared explicitly.- Lists are immutable so the sorted list is a new list.