Last updated on
Futures
Welcome to week 12 of CS-214 — Software Construction!
As usual, ⭐️ indicates the most important exercises and questions and 🔥 indicates the most challenging ones.
You do not need to complete all exercises to succeed in this class, and you do not need to do all exercises in the order they are written.
We strongly encourage you to solve the exercises on paper first, in groups. After completing a first draft on paper, you may want to check your solutions on your computer. To do so, you can download the scaffold code (ZIP).
Predicting future runtimes ⭐️
Let’s start our exploration of future with a series of check-yourself question: can you predict the runtime of various combinations of futures?
We assume the following two utility functions:
def sleep(durationSec: Int): Future[Unit] =
Future(Thread.sleep(durationSec * 1000))
src/main/scala/futures/runtime/runtimePrediction.scala
def wait[T](message: String)(f: Future[T]): Unit =
val start = System.currentTimeMillis
Await.result(f, Duration.Inf)
val elapsed = (System.currentTimeMillis - start) / 1000.0
println(f" $message: ${elapsed}%.0f seconds")
src/main/scala/futures/runtime/runtimePrediction.scala
The first sleeps for durationSec
sections; the second measures how long f
takes to exit and then prints message
along with the elapsed time.
Given these, can you predict the output of each of the lines below?
@main def predictionMain(): Unit =
println("Single operators:")
println(" flatMap:")
wait("2flatMap4"):
sleep(2).flatMap(_ => sleep(4))
wait("4flatMap2"):
sleep(4).flatMap(_ => sleep(2))
println(" zip:")
wait("2 zip 4"):
sleep(2).zip(sleep(4))
wait("4 zip 2"):
sleep(4).zip(sleep(2))
println(" sequence (;):")
wait("2 ; 4"):
sleep(2); sleep(4)
wait("4 ; 2"):
sleep(4); sleep(2)
println("Combinations:")
wait("2flatMap4 zip 1flatMap6"):
sleep(2).flatMap(_ => sleep(4))
.zip(sleep(1).flatMap(_ => sleep(6)))
wait("2zip1 flatMap 4zip6"):
sleep(2).zip(sleep(1)).flatMap(_ =>
sleep(4).zip(sleep(6))
)
wait("2zip1 flatMap (4;6)"):
sleep(2).zip(sleep(1)).flatMap(_ =>
sleep(4); sleep(6)
)
wait("2zip1 flatMap (6;4)"):
sleep(2).zip(sleep(1)).flatMap(_ =>
sleep(6); sleep(4)
)
println("Early start:")
wait("A=2 B=4 AflatMapB"):
val a = sleep(2)
val b = sleep(4)
a.flatMap(_ => b)
wait("A=2 B=4 AzipB"):
val a = sleep(2)
val b = sleep(4)
a.zip(b)
src/main/scala/main.scala
Making new friends ⭐️
In this exercise, we will explore different styles of writing asynchronous code in Scala: the “callback” style, the Future
“monadic” style, and the Future
“direct” style.
The Social Network
Consider a directed graph where nodes are users of a social network, and edges represent friendship. In our model, friendship is a symmetric relation: if a
is friends with b
, then b
is friends with a
.
Let Database
be a trait that provides methods to get and update users:
trait Database:
/** Gets a user by its id asynchronously.
*
* @param id
* Id of the user.
* @return
* A `Future` that will resolve to the queried [[User]].
*/
def getUser(id: String): Future[User]
/** Updates a user in the database asynchronously.
*
* @param currentValue
* Current value of the user.
* @param newValue
* New value of the user.
* @return
* A `Future` that will resolve to `true` if the operation was successful,
* or `false` otherwise.
*/
def updateUser(currentValue: User, newValue: User): Future[Boolean]
/** Gets a user by its id asynchronously.
*
* @param id
* Id of the user.
* @param callback
* Callback to call with the queried [[User]].
*/
def getUserWithCallback(id: String)(callback: User => Unit): Unit
/** Updates a user in the database asynchronously.
*
* @param currentValue
* Current value of the user.
* @param newValue
* New value of the user.
* @param callback
* Callback to call with the result of the operation. The result will be
* `true` if the operation was successful, or `false` otherwise.
*/
def updateUserWithCallback(currentValue: User, newValue: User)(callback: Boolean => Unit): Unit
src/main/scala/futures/friends/package.scala
Your task is to write a function makeFriends
. The function should take two user identifiers user1Id: String
anduser2Id: String
, and a db: Database
as parameters, and update the list of friends of both users to add the other user asynchronously. This means that the function should return immediately, and the friend list should be updated eventually.
In order to achieve this, you will call asynchronous functions to get and update users.
The User
class is defined as follows:
case class User(id: String, friends: List[String])
src/main/scala/futures/friends/package.scala
To make two users friends, you will need to:
- Get the first user from the database.
- Add the second user to the first user’s friend list.
- Update the first user in the database.
- Get the second user from the database.
- Add the first user to the second user’s friend list.
- Update the second user in the database.
- Notify the caller that the operation is complete.
To copy a User
with a new list of friends, you can use its copy
: method:
user.copy(friends = ???)
Sequential execution
In the first versions of makeFriends
, we will perform the operations sequentially, one after the other:
In this representation, the arrows represent dependencies between operations. If there is an arrow from a
to b
, it means that b
should only be executed after a
has completed.
Callback style
The “callback” style was mostly used in the past, and is still used in some libraries. In this style, each asynchronous operation takes an additional callback function as a parameter, which is called with the desired result when the operation completes.
In this style, the makeFriendsWithCallback
function takes an additional parameter callback: (Boolean, Boolean) => Unit
which is called when the operation completes:
def makeFriendsWithCallback(
user1Id: String,
user2Id: String,
db: Database
)(
callback: ((Boolean, Boolean)) => Any
): Unit =
???
src/main/scala/futures/friends/package.scala
The result of the operation is a pair of booleans, indicating whether the updates of the two users were successful.
Implement makeFriendsWithCallback
by calling getUserCallback
and updateUserCallback
appropriately.
Testing
To test your code, copy your implementations along with the scaffold code in your favorite editor and run
makeFriendsMain
with the appropriate version identifier, here callbacks
:
sbt:futures> runMain makeFriendsMain callbacks
The main function is run with a mock database where each operation takes 1 second to complete. As you you implemented the operations sequentially, the total time to complete the operation should be 4 seconds.
A correct implementation should print the following:
[info] running makeFriendsMain callbacks
Using callbacks
Waiting for callback to complete...
Get user aisha
Get user carlos
Update user aisha
Update user carlos
Aihsa and Carlos are now friends!
result: (true,true)
Completed in: 4 seconds
Monadic style, with flatMap
and map
Let’s now use Future
s, in the “monadic” style. In this style, each asynchronous operation returns a Future
object, which represents the result of the operation. We can then use methods such as map
and flatMap
one the Future
to transform and combine it with other Future
s.
In this style, we never await on a Future
directly, except at the very end of the program; your implementation must not call Await.result
, Await.ready
or await
.
Complete the makeFriendsMonadicFlatMap
function by calling the getUser
and updateUser
, and combining the resulting Future
s using flatMap
and map
:
def makeFriendsMonadicFlatMap(
user1Id: String,
user2Id: String,
db: Database
): Future[(Boolean, Boolean)] =
???
src/main/scala/futures/friends/package.scala
Testing
Yon can test your implementation by running the runMain makeFriendsMain monadic-flatmap
. A correct implementation should print the following:
Using monadic style and flatMap
Waiting for future to complete...
Get user aisha
Update user aisha
Get user carlos
Update user carlos
Aihsa and Carlos are now friends!
result: (true,true)
Completed in: 4 seconds
Monadic style, with for-comprehensions
As you have learned, combinations of flatMap
and map
can be written more concisely using for-comprehensions. Rewrite the previous solution using for-comprehensions:
def makeFriendsMonadicFor(
user1Id: String,
user2Id: String,
db: Database
): Future[(Boolean, Boolean)] =
???
src/main/scala/futures/friends/package.scala
Testing
You can test your implementation by running the runMain makeFriendsMain monadic-for
. It should complete in 4 seconds.
Direct style
Finally, let’s consider Future
s in the “direct” style, which is a more recent style that is gaining popularity in many languages. In this style, we also use Future
s, but we allow calling await
from inside Future
s whenever we want. This is the future of Future
s in Scala, but is not yet ready for production use for the reasons explained in the lecture.
In this style, the makeFriends
function also returns a Future[(Boolean, Boolean)]
:
def makeFriendsDirect(
user1Id: String,
user2Id: String,
db: Database
): Future[(Boolean, Boolean)] =
???
src/main/scala/futures/friends/package.scala
Implement it by calling the getUser
and updateUser
functions appropriately.
We also provide you with the following await
extension method on Future
that you can use to block until the result of a future is available:
extension [T](self: Future[T])
def await: T = Await.result(self, 3.seconds)
src/main/scala/futures/await.scala
In the direct style, you can call await
, as long you are inside a Future
, so that you don’t block the main thread.
Testing
You can test your implementation by running the runMain makeFriendsMain direct
. It should complete in 4 seconds.
Parallel execution
Can we make the makeFriends
function faster by executing some of the operations in parallel? Yes, we can!
Updating the two users can be done in parallel, since they are independent. Only the final operation, notifying the caller, depends on the two updates:
Monadic style
Implement makeFriendsMonadicParallel
:
def makeFriendsMonadicParallel(
user1Id: String,
user2Id: String,
db: Database
): Future[(Boolean, Boolean)] =
???
src/main/scala/futures/friends/package.scala
Hint
You can use a combination of flatMap
and map
, or zip
, to combine two Future
s into a single Future
that completes when both Future
s have completed.
Testing
You can test your implementation by running the runMain makeFriendsMain monadic-flatmap-par
. It should complete in 2 seconds.
Direct style
Implement makeFriendsDirectParallel
:
def makeFriendsDirectParallel(
user1Id: String,
user2Id: String,
db: Database
): Future[(Boolean, Boolean)] =
???
src/main/scala/futures/friends/package.scala
Testing
You can test your implementation by running the runMain makeFriendsMain direct-par
. It should complete in 2 seconds.
Reimplementing parallel collections ⭐️
Remember parallel collections? How could you implement your own parallel collections using Future
s?
In this exercise, you will implement ParList
, a wrapper around a List
that provides map
, flatMap
and filter
methods that execute their argument functions in parallel.
def map[U](f: T => U): ParList[U] =
???
src/main/scala/futures/parallel/ParList.scala
def flatMap[U](f: T => ParList[U]): ParList[U] =
???
src/main/scala/futures/parallel/ParList.scala
def filter(p: T => Boolean): ParList[T] =
???
src/main/scala/futures/parallel/ParList.scala
You can use Future.sequence
to combine a list of Future
s into a single Future
that completes when all the Future
s have completed.
Task manager
In this exercise, you will implement a simple command-line task manager. To start the REPL of the task manager, run runMain tasksMain
in the sbt console.
Adding tasks
The first command to implement is add
. It has the form of add <time> <text>
, which runs a task that first waits for <time>
seconds then prints out <text>
.
Right now the tasks run synchronously.
The REPL is blocked while the task is running, and you can only enter a new command after the task has completed.
sbt:futures> runMain tasksMain
[info] compiling 1 Scala source to /Users/linyxus/Workspace/course-material/exercises/futures/target/scala-3.3.1/classes ...
> add 10 hello
Started task
Finished task: hello
>
Now your task is to make the tasks run asynchronously, by modifying the following definition:
case ADD_RE(duration, result) =>
println(s"Started task")
Thread.sleep(duration.toLong * 1000)
println(s"Finished task: $result")
loop()
src/main/scala/futures/tasks/managerRepl.scala
Start by taking a look at the code in managerRepl.scala
to get familiar with how the following is used throughout the program:
val tasks = collection.mutable.ArrayBuffer.empty[Future[String]]
src/main/scala/futures/tasks/managerRepl.scala
Here, ADD_RE
is a regex that matches the syntax of the add
command.
Try numbering the tasks to distinguish them. The REPL should now work like the following:
sbt:futures> runMain tasksMain
[info] running tasksMain
> add 5 hello
> Started task #0
add 6 world
> Started task #1
Finished task #0: hello
Finished task #1: world
Tasks with dependencies
Implement another command addafter <depid> <time> <text>
, which creates a tasks that waits for <time>
seconds then prints out <text>
,
but starts running only after the task with id <depid>
has completed.
sbt:futures> runMain tasksMain
[info] running tasksMain
> add 5 hello
> Started task #0
addafter 0 1 world
> Finished task #0: hello
Started task #1
Finished task #1: world
In this example, we start a task (numbered 0) that prints hello
after 5 seconds, then starts another task (numbered 1) which runs after the first one and prints world
after 1 second.
Task #1 starts only after #0 has completed.
Complete the following sketch.
case ADD_AFTER_RE(after, duration, result) =>
???
src/main/scala/futures/tasks/managerRepl.scala
Utility methods of future ⭐️
Implement the following utility methods of Future
:
Hint
You may want to manipulate a Promise
rather than futures directly.
extension [T](self: Future[T])
def map[U](f: T => U): Future[U] =
???
src/main/scala/futures/ops/futureOps.scala
extension [T](self: Future[T])
def flatMap[U](f: T => Future[U]): Future[U] =
???
src/main/scala/futures/ops/futureOps.scala
extension [T](self: Future[T])
def zip[U](other: Future[U]): Future[(T, U)] =
???
src/main/scala/futures/ops/futureOps.scala
def sequence[T](futures: List[Future[T]]): Future[List[T]] =
???
src/main/scala/futures/ops/futureOps.scala
def race[T](futures: List[Future[T]]): Future[T] =
???
src/main/scala/futures/ops/futureOps.scala
You can play with your implementation by running runMain futuresTest
in the sbt console.
GitHub contributors ⭐️
In this exercise, you will implement a function that retrieves the list of contributors of a GitHub repository, and display their names and location:
sbt:futures> runMain showContributorsMain nasa marsapi 3
[info] running (fork) showContributorsMain nasa marsapi 3
[info] Getting contributors of nasa/marsapi
[info] John from Lausanne, Switzerland
[info] Mary from somewhere
[info] James from Bangalore, India
[info] Patricia from Sydney
[info] Someone from Tokyo
The arguments to the showContributorsMain
function are:
- The name of a GitHub organization.
- The name of a GitHub repository.
- The number of contributors to get per page when querying the GitHub API (see below).
GitHub API
In order to get the list of contributors of a GitHub repository, we will use the GitHub REST API, in particular the list contributors endpoint and the get user endpoint:
- The first enables us to get the list of contributors of a repository. For example, we can get the list of contributors of the
lampepfl/gears
repository by sending a request to https://api.github.com/repos/lampepfl/gears/contributors. - The second enables us to get the details of a user. For example, we can get the details of the user
odersky
by sending a request to https://api.github.com/users/odersky.
Pagination
The GitHub API paginates the results of most endpoints that return sequences to avoid returning too much data at once.
You can control pagination using two URL parameters:
per_page
: the number of results to return per page. This will be thepageSize
parameter of theshowContributorsMain
andshowContributors
functions.page
: the page number to return.
Try it with the lampepfl/gears
repository!
- https://api.github.com/repos/lampepfl/gears/contributors?per_page=2
- https://api.github.com/repos/lampepfl/gears/contributors?per_page=2&page=2
- https://api.github.com/repos/lampepfl/gears/contributors?per_page=2&page=3
- https://api.github.com/repos/lampepfl/gears/contributors?per_page=2&page=4
Scala API
To call the GitHub API, we provide you with a get
function that handles the JSON parsing for you. It takes a URL and returns a Future[Response[T]]
:
def get[T](
/** The URL to call. */
uri: Uri
)(
/** The HTTP backend used to send requests. */
using backend: HttpBackend
)(
/** Used to decode the response JSON to a `T`. */
using Reader[T]
): Future[Response[T]] =
src/main/scala/futures/rest/utils.scala
The Response
contains the following 2 member that you will need to use:
- a
body
field of typeT
, which contains the parsed JSON response. - a
header(String)
method, which returns the value of the given HTTP header.
You can for example use it as follows to query the contributors of the nasa/marsapi
repository:
// The `uri` interpolator is used to create a `Uri` from a string.
val url = uri"$API_URL/repos/nasa/marsapi/contributors?per_page=3"
// `get[T]` returns a `Future[T]`.
get[List[Contributor]](url)
// We `map` over the `Future` to access the response.
.map(res =>
// res.body` is a `List[RepoContributor]`:
assertEquals(
res.body,
List(
Contributor(f"$API_URL/users/john"),
Contributor(f"$API_URL/users/mary"),
Contributor(f"$API_URL/users/james")
)
)
// Each `Response` contains a `Link` header with a link to the next
// page. You can use the `getNextPageUrl` function to extract the link.
// It returns an `Option[String]`:
assertEquals(
getNextPageUrl(res.header("Link")),
Some(f"$API_URL/repos/0/contributors?per_page=3&page=2")
)
)
src/test/scala/futures/rest/GithubStubBackendTest.scala
Contributor
is defined as:
case class Contributor(
/** API URL of the corresponding user. */
url: String
) derives ReadWriter
src/main/scala/futures/rest/contributors.scala
To query the details of a user, you can use the get
function as follows:
val url = uri"$API_URL/users/jennifer"
get[User](url)
.map(res =>
assertEquals(
res.body,
User(Some("Jennifer"), Some("Nairobi"))
)
)
src/test/scala/futures/rest/GithubStubBackendTest.scala
User
is defined as:
case class User(
/** Full name of the user. */
name: Option[String],
/** Location of the user. */
location: Option[String]
) derives ReadWriter:
override def toString(): String =
val n = name.getOrElse("Someone")
val l = location.getOrElse("somewhere")
f"$n from $l"
src/main/scala/futures/rest/contributors.scala
Stub API
The Github API is rate limited to 60 unauthenticated requests per minute.
To avoid hitting this limit and simplify testing, we provide you with a stub API that you can use instead of the real GitHub API. You can use it by providing githubStubBackend
as the HttpBackend
parameter to the get
function.
The examples of the previous section use the stub API.
See also how we use it in the showContributorsMain
implementation:
@main def showContributorsMain(
org: String,
repo: String,
perPage: Int
): Unit =
// given HttpBackend = sttp.client4.HttpClientFutureBackend
given HttpBackend = githubStubBackend
given Window = ConsoleWindow
println(f"Getting contributors of $org/$repo")
Await.result(showContributorsDirect(org, repo, perPage), 2.seconds)
src/main/scala/main.scala
Our stub API only handles requests to the user and contributors endpoints, and only for two fake repositories: nasa/marsapi
and sleepysloths/lazyloader
which respectively have 5 and 2 stub contributors. All other requests will fail with a 404 Not Found error.
If you want to use the real GitHub API, you can set the given HttpBackend
to HttpClientFutureBackend()
.
Order of execution
The crucial part of this exercise is the order in which the requests are executed and the results printed.
The following diagram illustrates the order in which the requests should be executed. If there is an arrow from A
to B
, it means that B
should only be executed after A
has completed.
When two requests are independent, they can and must be executed in parallel. For example, the requests to get the details of users $1
, $2
and $3
, and the request to get the second page of contributors should all be executed in parallel.
Note also that the details of users should be printed per page, as soon as the details of all users on that page have been retrieved.
Implementation
Your task is to write a showContributors
method that takes 5 parameters:
org
: the name of the GitHub organization.repo
: the name of the GitHub repository.pageSize
: the number of contributors to get per page when querying the GitHub API.- an implicit
HttpBackend
to use to query the GitHub API. - an implicit
Window
object which contains a singleprintln
method, that you should use to print the results.
Write two versions:
- A version in monadic style, where you use the
zip
,flatMap
andmap
methods ofFuture
, andFuture.sequence
to combine multiple futures. - A version in direct style: where you use the provided
await
extension method onFuture
to block until the result of a future is available.
Hint: scaffold suggestion for the monadic style version
def showContributors(
/** The organization that owns the repository. */
org: String,
/** The name of the repository. */
repo: String,
/** The number of contributors per page. */
perPage: Int
)(using
/** The HTTP backend used to send requests, used by `get`. */
HttpBackend,
/** Used to print the results. */
Window
): Future[Unit] =
def showNextPage(
pageUri: Uri,
prevPagePrinted: Future[Unit]
): Future[Unit] =
???
val url = uri"$API_URL/repos/$org/$repo/contributors?per_page=$perPage"
showNextPage(url, Future.successful(()))
src/main/scala/futures/rest/contributors.scala
Hint: scaffold suggestion for the direct style version
def showContributorsDirect(
org: String,
repo: String,
perPage: Int
)(using HttpBackend, Window): Future[Unit] =
def showNextPageDirect(
pageUri: Uri,
prevPagePrinted: Future[Unit]
): Future[Unit] =
Future:
???
val url = uri"$API_URL/repos/$org/$repo/contributors?per_page=$perPage"
showNextPageDirect(url, Future.successful(()))
src/main/scala/futures/rest/contributors.scala
Exam Questions
- Final Questions: 2023, Exercise “Music Streaming Service”