Skip to content

Commit 91c1d10

Browse files
committed
New examples of the emerging "direct style" features in Scala 3.3.0
1 parent e2a5e7d commit 91c1d10

File tree

5 files changed

+283
-0
lines changed

5 files changed

+283
-0
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
// src/main/scala/progscala3/concurrency/direct/BoundaryExamples.scala
2+
package progscala3.concurrency.boundary
3+
4+
import scala.util.boundary, boundary.break
5+
import progscala3.concurrency.boundary.optional.*
6+
7+
object BoundaryExamples:
8+
def firstIndex[T](xs: Seq[T], elem: T): Int =
9+
boundary:
10+
for (x, i) <- xs.zipWithIndex do
11+
if x == elem then break(i)
12+
-1
13+
14+
def firstTwoIndices[T](xs: Seq[T], elem1: T, elem2: T): (Int, Int) =
15+
boundary:
16+
firstIndex(xs, elem1) match
17+
case -1 => break((-1, -1))
18+
case i => break((i, firstIndex(xs, elem2)))
19+
20+
/**
21+
* For a sequence (rows) of sequences (columns) return a sequence with just
22+
* the first column (i.e., the first element in each row). Because a row might
23+
* be empty, wrap the returned value in an Option. If any row is empty, return None
24+
* for the whole thing.
25+
* An example using "optional". See comments in `optional.scala` for details.
26+
*/
27+
def firstColumn[T](xss: Seq[Seq[T]]): Option[Seq[T]] =
28+
optional:
29+
xss.map(_.headOption.?)
30+
31+
def main(args: Array[String]) =
32+
val xs = (0 until 10).toSeq
33+
assert(BoundaryExamples.firstIndex(xs, 0) == 0)
34+
assert(BoundaryExamples.firstIndex(xs, 9) == 9)
35+
assert(BoundaryExamples.firstIndex(xs, -1) == -1)
36+
assert(BoundaryExamples.firstIndex(xs, 10) == -1)
37+
assert(BoundaryExamples.firstTwoIndices(xs, 0, 9) == (0, 9))
38+
assert(BoundaryExamples.firstTwoIndices(xs, 9, 0) == (9, 0))
39+
assert(BoundaryExamples.firstTwoIndices(xs, -1, 0) == (-1, -1))
40+
assert(BoundaryExamples.firstTwoIndices(xs, 10, 0) == (-1, -1))
41+
assert(BoundaryExamples.firstTwoIndices(xs, 0, -1) == (0, -1))
42+
assert(BoundaryExamples.firstTwoIndices(xs, 0, 10) == (0, -1))
43+
44+
val xssSome = List(List(0), List(1,0), List(2,1,0), List(3,2,1,0))
45+
val xssNone = List(List(0), Nil, List(2,1,0), List(3,2,1,0))
46+
assert(firstColumn(xssSome) == Some(List(0,1,2,3)))
47+
assert(firstColumn(xssNone) == None)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
# README for `direct`
2+
3+
> **Note:** A more complete version of this text can be found at [Dean's Scala 3 blog](https://medium.com/scala-3).
4+
5+
This directory was added in June 2023, well after _Programming Scala, Third Edition_ was published. The code in this section illustrates the new _direct style_ that was added to Scala starting in version 3.3.0.
6+
7+
I recommend viewing [this Martin Odersky talk](https://www.youtube.com/watch?v=0Fm0y4K4YO8) about the subject. The following discussion is based on it.
8+
9+
The idea of _direct style_ is to explore how we can create and use primitives, such as concurrency abstractions, that don't require the boilerplate of monads. For motivation, consider the example of `Future`s. You currently use them like this:
10+
11+
```scala
12+
val sum =
13+
val f1 = Future(c1.read)
14+
val f2 = Future(c2.read)
15+
for
16+
x <- f1
17+
y <- f2
18+
yield x + y
19+
```
20+
21+
The `for` comprehension invokes `map` and `flatMap` to wait on the futures and extract the values.
22+
23+
A new _direct style_ implementation of futures would be used liked this:
24+
25+
```scala
26+
val sum = Future:
27+
val f1 = Future(c1.read)
28+
val f2 = Future(c2.read)
29+
f1.value + f2.value
30+
```
31+
32+
In both cases, the two futures are executing in parallel, which might take a while. The
33+
sum of the returned values is returned in a new future. The `value` method returns the result of a future once it is available or it throws an exception if the future returns a `Failure`. However, using the `boundary` and `break` mechanism discussed below, this exception would be caught by the `Future` library and used to cancel the other running `Future`, if one is still running, and then return a "failed" future for `sum`.
34+
35+
So, direct style simplifies code, making it is easier to write and understand, plus it enables cleaner separation of concerns, such as handling timeouts and failures in futures, and it cleanly supports composability, which monads don't provide unless you use cumbersome machinary such as monad transformers.
36+
37+
## Implementing Direct Style in Scala
38+
39+
In the talk, Martin discusses four aspects of building support for direct style in Scala:
40+
41+
1. `boundary` and `break` - now available in Scala 3.3.0.
42+
2. Error handling - enabled, but not yet used in the library, which is still the Scala 2.13 library.
43+
3. Suspensions - work in progress.
44+
3. Concurrent library design built on the above - work in progress.
45+
46+
## `boundary` and `break`
47+
48+
This mechanism is defined with a new addition to the API, [`scala.util.boundary$`](https://www.scala-lang.org/api/3.3.0/scala/util/boundary$.html). It provides a cleaner alternative to non-local returns.
49+
50+
* `boundary` defines a context for a computation.
51+
* `break` returns a value from within the enclosing boundary.
52+
53+
Here is an example from Martin's talk, which is also discussed in the [API](https://www.scala-lang.org/api/3.3.0/scala/util/boundary$.html):
54+
55+
```scala
56+
import scala.util.boundary, boundary.break
57+
58+
def firstIndex[T](xs: List[T], elem: T): Int =
59+
boundary:
60+
for (x, i) <- xs.zipWithIndex do
61+
if x == elem then break(i)
62+
-1
63+
```
64+
65+
`BoundaryExamples.scala` in this directory also implements this method. (There is a test for it in `.../test/scala/concurrency/direct/BoundaryExamplesSuite.scala`.)
66+
67+
As shown, `break` can optionally return a value.
68+
69+
Here is a slightly simplified implementation. (The full 3.3.0 source code is [here](https://github.com/lampepfl/dotty/blob/3.3.0/library/src/scala/util/boundary.scala)):
70+
71+
```scala
72+
package scala.util
73+
74+
object boundary:
75+
final class Label[-T]
76+
77+
inline def apply[T](inline body: Label[T] ?=> T): T = ...
78+
79+
def break[T](value: T)(using label: Label[T]): Nothing =
80+
throw Break(label, value)
81+
82+
final class Break[T] (val label: Label[T], val value: T) extends RuntimeException(...)
83+
84+
end boundary
85+
```
86+
87+
In the example above, the `boundary:` line is short for `boundary.apply:` with the indented code below it passed as the body.
88+
89+
Well actually, the `block` passed to `apply` is a _context function_ that is called within `apply` to return the block of code shown in the example. Note the `final class Label[T]` declaration in `boundary`. Users don't define `Label` instances themselves. Instead, this is done by the implementation of `boundary.apply` to provide the _capability_ of doing a non-local return. Using a `Label` in this way prevents the user from trying to call `break` without an enclosing `boundary`.
90+
91+
Rephrasing all that, we don't want users to call `break` without an enclosing `boundary`. That's why `break` requires an in-scope given instance of `Label`, which the implementation of `boundary.apply` creates before it calls `body` (not shown). When your code block is executed, if it calls `break`, a given `Label` is in-scope.
92+
93+
You don't have to do anything to create the context function passed to `boundary.apply`. It is synthesized from your block of code automatically when `boundary.apply` is called.
94+
95+
Look at `firstIndex()` again. If we do find an element that is equal to `elem`, then we call break to return the index `i` from the `boundary`. If we don't find the element, then a "normal" return is used to return `-1`. We never reach the `-1` expression if `break` is called.
96+
97+
This directory includes a second example, `optional.scala` that is discussed in the video. See the comments in that file for details and an example of usage in `BoundaryExamples.scala`.
98+
99+
### Other Benefits
100+
101+
This implementation is a better alternative to `scala.util.control.NonLocalReturns` and `scala.util.control.Breaks` which are deprecated as of Scala 3.3.0. The new feature is easier for developers to use and it adds the following additional benefits:
102+
103+
* The implementation uses a new [`scala.util.boundary$.Break`](https://www.scala-lang.org/api/3.3.0/scala/util/boundary$$Break.html) class that derives from `RuntimeException`. Therefore, `break`s are logically non-fatal exceptions and the implementation is optimized to suppress unnecessary stack trace generation.
104+
* Better performance is provided when a `break` occurs to the enclosing scope inside the same method (i.e., the same stack frame), where it can be rewritten to a jump call.
105+
106+
## A New Concurrency Library
107+
108+
Back to the futures :) , `boundary` and `break` can be used for adding new concurrency abstractions to Scala following a direct style, like the `Futures` example above. The implementation is not trivial for Scala, because,
109+
110+
1. Scala now runs on three platforms: JVM, JavaScript, and native.
111+
2. Even on the JVM, using the new lightweight fibers coming in [Project Loom](https://wiki.openjdk.org/display/loom/Main) would only be available to users on the most recent JVMs (19 and later).
112+
113+
Possible implementation approaches include using source or bytecode rewriting.
114+
115+
So, the implementation will be non-trivial, but work has started in the [`lampepfl/async`](https://github.com/lampepfl/async) repo, a "strawman" for ideas, both for conceptual abstractions for concurrency (like a new `Future` type), as well as implementations.
116+
117+
### A Comparison with Ray
118+
119+
The direct style for `Futures` above looks a lot like working with tasks and actors in [Ray](https://ray.io), the Python-centric concurrency library that is becoming popular for computationally-heavy projects, like ML/AI. [I really like that API](https://medium.com/distributed-computing-with-ray/ray-for-the-curious-fa0e019e17d3) for its simplicity and concision for users. The Ray abstractions heavily rely on the metaprogramming flexibility in a dynamically-typed language like Python, while the highly-scalable, backend services for distributed computation are written in C++.
120+
121+
In contrast, the Scala abstractions are based on more principled and flexible foundations that are part of Scala 3 itself (discussed below), which keep the implementations very concise. However, the implementations target different users. Ray is designed for large-scale cluster computation, as well as local multi-threading. The equivalent in Scala would require more extensive backend libraries, such as an optional Akka implementation.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
// src/main/scala/progscala3/concurrency/direct/optional.scala
2+
package progscala3.concurrency.boundary
3+
4+
import scala.util.boundary, boundary.break, boundary.Label
5+
6+
/**
7+
* Try a computation that will hopefully return `Some(thing)`, but if it can't
8+
* return the `thing`, then return `None`.
9+
* See an example usage in BoundaryExamples.scala
10+
*/
11+
object optional:
12+
/**
13+
* Like `boundary.apply()` this method defines a boundary for the computation.
14+
* The reason the `Label` is typed with `None.type`, is because if we call `break`,
15+
* it is because we are returning `None` to the boundary, not `Some(thing)` that we
16+
* would otherwise return.
17+
*/
18+
inline def apply[T](inline body: Label[None.type] ?=> T): Option[T] =
19+
boundary(Some(body))
20+
21+
/**
22+
* Inspired by a similar-looking construct in Rust, if you have an `Option` instance,
23+
* calling `?` on it will either return the enclosed object or break to the enclosing
24+
* boundary with `None` as the value returned at the boundary.
25+
*/
26+
extension [T](r: Option[T])
27+
inline def ? (using label: Label[None.type]): T = r match
28+
case Some(x) => x
29+
case None => break(None)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
// src/main/scala/progscala3/concurrency/direct/Suspension.scala
2+
package progscala3.concurrency.boundary
3+
4+
import scala.util.boundary, boundary.break, boundary.Label
5+
6+
// A.k.a. "delimited continuations".
7+
// Can be used expressed to express algebraic effects and monads.
8+
class Suspension[-T, +R]:
9+
def resume(arg: T): R = ???
10+
11+
def suspend[T, R](body: Suspension[T, R] => R)(using Label[R]): T = ???
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
// src/test/scala/progscala3/concurrency/direct/BoundaryExamplesSuite.scala
2+
package progscala3.concurrency.boundary
3+
4+
import munit.ScalaCheckSuite
5+
import org.scalacheck.*
6+
7+
class BoundaryExamplesSuite extends ScalaCheckSuite:
8+
import Prop.forAll
9+
10+
property("BoundaryExamples.firstIndex returns an index between 0 and the sequence size minus one if the element exists") {
11+
forAll(Gen.choose(1, 10)) { case size: Int =>
12+
val top = size - 1
13+
val xs = (0 until size).toList
14+
xs.forall(i => BoundaryExamples.firstIndex(xs, i) == i)
15+
}
16+
}
17+
18+
property("BoundaryExamples.firstIndex returns -1 if the element does not exist") {
19+
forAll(Gen.choose(1, 10)) { case size: Int =>
20+
val xs = (0 until size).toList
21+
BoundaryExamples.firstIndex(xs, -1) == -1 &&
22+
BoundaryExamples.firstIndex(xs, size) == -1
23+
}
24+
}
25+
26+
property("BoundaryExamples.firstTwoIndices returns indices between 0 and the sequence size minus one if the elements exist") {
27+
forAll(Gen.choose(1, 10)) { case size: Int =>
28+
val top = size - 1
29+
val xs = (0 until size).toList
30+
xs.combinations(2).forall { (iter: List[Int]) =>
31+
val i = iter(0)
32+
val j = iter(1)
33+
BoundaryExamples.firstTwoIndices(xs, i, j) == (i, j)
34+
}
35+
}
36+
}
37+
38+
property("BoundaryExamples.firstTwoIndices returns (-1, -1) if the first element does not exist") {
39+
forAll(Gen.choose(1, 10)) { case size: Int =>
40+
val xs = (0 until size).toList
41+
xs.forall { i =>
42+
BoundaryExamples.firstTwoIndices(xs, -1, i) == (-1, -1) &&
43+
BoundaryExamples.firstTwoIndices(xs, size, i) == (-1, -1)
44+
}
45+
}
46+
}
47+
48+
property("BoundaryExamples.firstTwoIndices returns (n, -1) if the second element does not exist, but the first element is found at index n") {
49+
forAll(Gen.choose(1, 10)) { case size: Int =>
50+
val xs = (0 until size).toList
51+
xs.forall { i =>
52+
BoundaryExamples.firstTwoIndices(xs, i, -1) == (i, -1) &&
53+
BoundaryExamples.firstTwoIndices(xs, i, size) == (i, -1)
54+
}
55+
}
56+
}
57+
58+
property("BoundaryExamples.firstColumn returns Some(List(...)) with the first elements if every input 'column' has at least one element") {
59+
forAll(Gen.choose(1, 10)) { case size: Int =>
60+
val xsSome = (0 to size).toList
61+
val xssSome = xsSome.map(i => (i to 0 by -1).toList)
62+
BoundaryExamples.firstColumn(xssSome) == Some(xsSome)
63+
}
64+
}
65+
66+
property("BoundaryExamples.firstColumn returns None if any input 'column' is empty") {
67+
forAll(Gen.choose(1, 10)) { case size: Int =>
68+
val xsSome = (0 to size).toList
69+
val xssSome = xsSome.map(i => (i to 0 by -1).toList)
70+
val xssNone = xsSome.map(i => xssSome.updated(i, List.empty[Int]))
71+
xssNone.forall { xss =>
72+
BoundaryExamples.firstColumn(xss) == None
73+
}
74+
}
75+
}

0 commit comments

Comments
 (0)