Functions as Values
Pass functions as arguments, return them from methods, compose them, and use by-name parameters to build your own control structures.
In Scala a function is a value: you can put one in a val, pass it to a method, return one,
and store a list of them. Most of the collections API in lesson 2 exists because of this.
A function in a val
@main def run(): Unit =
val double: Int => Int = x => x * 2
val add: (Int, Int) => Int = (a, b) => a + b
val describe: Double => String = amt => if amt > 50 then "large" else "small"
println(double(21))
println(add(3, 4))
println(describe(75.0))
println(List(1, 2, 3).map(double))
42
7
large
List(2, 4, 6)
Int => Int is a type like any other. The last line passes double where map expects a
function — no wrapping needed, because it already is one.
Taking a function as a parameter
case class Order(id: Int, country: String, status: String, amount: Double)
val orders = List(
Order(1001, "GB", "completed", 25.50),
Order(1002, "US", "completed", 12.00),
Order(1003, "GB", "returned", 40.00),
Order(1004, "GB", "completed", 8.75),
)
def summarise(os: List[Order], keep: Order => Boolean, value: Order => Double): Double =
os.filter(keep).map(value).sum
@main def run(): Unit =
println(summarise(orders, _.status == "completed", _.amount))
println(summarise(orders, _.country == "GB", _.amount))
println(summarise(orders, _ => true, _ => 1.0))
46.25
74.25
4.0
One method, three questions, because the caller supplies the policy. That is the whole idea
behind filter, map, sortBy and the rest — they take the varying part as a function.
Returning a function
def discountFor(tier: String): Double => Double = tier match
case "gold" => amount => amount * 0.80
case "silver" => amount => amount * 0.90
case _ => amount => amount
@main def run(): Unit =
val gold = discountFor("gold")
val none = discountFor("bronze")
println(gold(100.0))
println(none(100.0))
println(orders.map(o => o.copy(amount = gold(o.amount))).map(_.amount))
80.0
100.0
List(20.4, 9.6, 32.0, 7.0)
discountFor runs the match once and returns a function specialised to that tier. Applying
it to a million orders does not re-run the decision.
Closures
def counter(): () => Int =
var n = 0
() =>
n += 1
n
@main def run(): Unit =
val next = counter()
println(next())
println(next())
println(next())
val other = counter()
println(other())
1
2
3
1
The returned function captured n — it outlives the call to counter that created it. Each
call to counter() gets a fresh n, which is why other starts at 1.
This is one of the few legitimate var uses, and it is also a warning: a closure over mutable
state is not thread-safe, and two threads calling next() can see the same number.
Composition
@main def run(): Unit =
val addVat: Double => Double = _ * 1.20
val addShipping: Double => Double = _ + 3.99
val round: Double => Double = a => BigDecimal(a).setScale(2, BigDecimal.RoundingMode.HALF_UP).toDouble
val checkout = addVat andThen addShipping andThen round
val reversed = round compose addShipping compose addVat
println(checkout(25.50))
println(reversed(25.50))
val steps = List(addVat, addShipping, round)
println(steps.reduce(_ andThen _)(25.50))
34.59
34.59
34.59
f andThen g applies f first; g compose f is the same thing written the other way round.
The last line composes a list of transformations — the shape of a configurable pipeline
where the steps are chosen at runtime.
Currying
def between(lo: Double)(hi: Double)(o: Order): Boolean =
o.amount >= lo && o.amount <= hi
@main def run(): Unit =
val midRange = between(10.0)(30.0)
println(orders.filter(midRange).map(_.id))
val cheap = between(0.0)
println(orders.filter(cheap(10.0)).map(_.id))
List(1001)
List(1004)
Multiple parameter lists let you apply some arguments now and the rest later. The other reason to curry is type inference:
def process[A, B](xs: List[A])(f: A => B): List[B] = xs.map(f)
@main def run(): Unit =
println(process(orders)(o => o.amount * 2))
List(51.0, 24.0, 80.0, 17.5)
A is fixed by the first list, so the compiler already knows o is an Order when it
checks the lambda. With one combined list you would have to annotate it. This is exactly why
foldLeft(z)(op) has two lists.
By-name parameters
def logIfEnabled(enabled: Boolean)(message: => String): Unit =
if enabled then println(message)
def expensive(): String =
println(" (building the message)")
"order summary: " + orders.map(_.amount).sum
@main def run(): Unit =
logIfEnabled(true)(expensive())
println("---")
logIfEnabled(false)(expensive())
println("done")
(building the message)
order summary: 86.25
---
done
With enabled = false, expensive() never ran — no “building the message” line. A by-name
parameter (=> String) is evaluated where it is used, not at the call site. This is how
logging libraries avoid paying for messages nobody prints.
It also lets you write things that look like syntax:
def retry[A](times: Int)(body: => A): A =
var attempt = 1
while true do
try return body
catch
case e: Exception if attempt < times =>
println(s" attempt $attempt failed: ${e.getMessage}")
attempt += 1
throw new IllegalStateException("unreachable")
@main def run(): Unit =
var calls = 0
val result = retry(3) {
calls += 1
if calls < 3 then throw new RuntimeException(s"boom $calls")
s"succeeded on attempt $calls"
}
println(result)
attempt 1 failed: boom 1
attempt 2 failed: boom 2
succeeded on attempt 3
retry(3) { ... } reads like a built-in construct. The block is passed unevaluated and run
once per attempt.
PartialFunction
@main def run(): Unit =
val statusLabel: PartialFunction[Order, String] =
case o if o.status == "completed" => s"${o.id}: paid £${o.amount}"
case o if o.status == "returned" => s"${o.id}: refunded"
println(orders.collect(statusLabel))
println(statusLabel.isDefinedAt(Order(9, "GB", "pending", 1.0)))
val amounts = List("25.50", "n/a", "12.00")
println(amounts.collect { case s if s.toDoubleOption.isDefined => s.toDouble })
List(1001: paid £25.5, 1003: refunded, 1004: paid £8.75)
List(25.5, 12.0)
collect keeps only the elements the partial function is defined for — filter and map in one
pass. Calling a partial function on an input it does not handle throws MatchError, so use
collect, isDefinedAt, or applyOrElse rather than applying it directly.
Practice
1. Write a method taking a predicate and use it three ways.
def countWhere(os: List[Order])(p: Order => Boolean): Int = os.count(p)
println(countWhere(orders)(_.amount > 20))
println(countWhere(orders)(_.country == "GB"))
println(countWhere(orders)(o => o.status == "completed" && o.amount < 20))
2
3
2
One method, three questions. Adding a fourth needs no change to countWhere.
2. Compose two transformations and apply them to a list.
val pipeline = ((_: Double) * 1.2) andThen (_ + 3.99)
println(orders.map(o => pipeline(o.amount)))
List(34.59, 18.39, 51.99, 14.49)
The type annotation on the first lambda is needed because andThen gives the compiler
nothing to infer from on its own.
3. Use a by-name parameter to skip expensive work.
(building the message)
order summary: 86.25
---
done
The second call printed nothing from expensive(). Change the parameter to message: String
and the side effect happens on both calls — proof that the => is doing the work.
4. Use collect with a partial function.
println(orders.collect { case Order(id, "GB", "completed", amt) => (id, amt) })
List((1001,25.5), (1004,8.75))
Non-matching elements are dropped rather than causing an error, which is the difference from
map. It is the neatest way to filter by shape and extract fields in one step.
Next: traits and given instances — Scala 3’s approach to abstraction and type classes.