Details On How The Debuggable Data Type Works (Scala 3 Video)
If you want to see the nitty-gritty details of how the Debuggable
class works with the f
, g
, and h
functions in the previous lesson, this lesson is for you. I won’t introduce any new concepts in this lesson, but I’ll add a lot of debugging statements to that code to show exactly how a for
expression like this works.
Source code
The best way to work with this lesson is to check the source code out from my Github project so you can easily refer to the code as you read the lesson. Here’s a link to the code:
The flow of this lesson
The way this lesson works is:
- I’ll show the same code as the previous lesson, but with many
println
statements added for debugging purposes - After showing the new code, I’ll show the output it produces
- After that, I’ll explain that output
I hope all of this debug code (and its output) provides a good example of how a for
expression works, especially with “wrapper” classes like Debuggable
.
The Debuggable
class
First, here’s the source code for the Debuggable
class:
case class Debuggable(value: Int, msg: String) {
def map(f: Int => Int): Debuggable = {
println("\n>>>> entered map >>>>")
println(s"map: value: ${value}")
println(s"map: msg: (${msg})")
val nextValue = f(value) //Int
// there is no `nextValue.msg`
println(s"map: nextValue: ${nextValue}")
println("<<<< leaving map <<<<\n")
Debuggable(nextValue, msg)
}
def flatMap(f: Int => Debuggable): Debuggable = {
println("\n>>>> entered fmap >>>>")
println(s"fmap: value: ${value}")
println(s"fmap: msg: (${msg})")
val nextValue = f(value)
println(s"fmap: msg: (${msg})")
println(s"fmap: next val: ${nextValue.value}")
println(s"fmap: next msg: \n(${nextValue.msg})")
println("<<<< leaving fmap <<<<\n")
Debuggable(nextValue.value, msg + "\n" + nextValue.msg)
}
}
The important notes about this code are:
- I added
println
statements to show thevalue
andmsg
thatmap
andflatMap
have before they apply their functions - I added
println
statements to show thenextValue
thatmap
andflatMap
have after they apply their functions, and also show the new message that’s created whenflatMap
applies its function
The f
, g
, and h
functions
Next, this is how I modified the f
, g
, and h
functions:
object DebuggableTestDetails extends App {
def f(a: Int): Debuggable = {
println(s"\n[f: a = $a]")
val result = a * 2
Debuggable(result, s"f: input: $a, result: $result")
}
def g(a: Int): Debuggable = {
println(s"\n[g: a = $a]")
val result = a * 3
Debuggable(result, s"g: input: $a, result: $result")
}
def h(a: Int): Debuggable = {
println(s"\n[h: a = $a]")
val result = a * 4
Debuggable(result, s"h: input: $a, result: $result")
}
val finalResult = for {
fRes <- f(100)
gRes <- g(fRes)
hRes <- h(gRes)
} yield hRes
println("\n----- FINAL RESULT -----")
println(s"final value: ${finalResult.value}")
println(s"final msg: \n${finalResult.msg}")
}
As shown, in the f
, g
, and h
functions I show the initial value that each function receives when it’s called.
How the for
expression is translated
In the output that follows it also helps to understand how the for
expression is translated by the Scala compiler. This is what my for
expression looks like:
val finalResult = for {
fRes <- f(100)
gRes <- g(fRes)
hRes <- h(gRes)
} yield hRes
And this is what the code looks like when I compile the for
expression with the scalac -Xprint:parse
command (and then clean up the output):
val finalResult = f(100).flatMap { fResult =>
g(fResult).flatMap { gResult =>
h(gResult).map { hResult =>
hResult
}
}
}
Notice that there are two flatMap
calls followed by one map
call.
The output
When I run the test App
shown, this is the output I see:
[f: a = 100]
>>>> entered fmap >>>>
fmap: value: 200
fmap: msg: (f: input: 100, result: 200)
[g: a = 200]
>>>> entered fmap >>>>
fmap: value: 600
fmap: msg: (g: input: 200, result: 600)
[h: a = 600]
>>>> entered map >>>>
map: value: 2400
map: msg: (h: input: 600, result: 2400)
map: nextValue: 2400
<<<< leaving map <<<<
fmap: msg: (g: input: 200, result: 600)
fmap: next val: 2400
fmap: next msg:
(h: input: 600, result: 2400)
<<<< leaving fmap <<<<
fmap: msg: (f: input: 100, result: 200)
fmap: next val: 2400
fmap: next msg:
(g: input: 200, result: 600
h: input: 600, result: 2400)
<<<< leaving fmap <<<<
----- FINAL RESULT -----
final value: 2400
final msg:
f: input: 100, result: 200
g: input: 200, result: 600
h: input: 600, result: 2400
Take a few moments to review that output to see if you understand how it works. One of the most important things to note is that the map
method is called last, and it returns before the flatMap
method calls return.
I’ll provide an explanation of all of the output in the sections that follow.
Explaining the output
The first thing that happens is that the f
function is called with the value 100
:
[f: a = 100]
You can understand this when you look at the first part of my for
expression:
val finalResult = for {
fRes <- f(100)
------
The desugared for
expression shows this even more clearly:
val finalResult = f(100).flatMap { fResult =>
------
In both of those code snippets, f(100)
is the first piece of code that is invoked.
What happens in f
What happens inside of f
is that this output is printed:
[f: a = 100]
After that, the value that’s received is doubled, and a new Debuggable
instance is created. You can see that in f
’s body:
def f(a: Int): Debuggable = {
println(s"\n[f: a = $a]")
val result = a * 2
Debuggable(result, s"f: input: $a, result: $result")
}
The first flatMap
is called
The next piece of output looks like this:
>>>> entered fmap >>>>
fmap: value: 200
fmap: msg: (f: input: 100, result: 200)
This shows that flatMap
is invoked. This makes sense when you look at the first line of the desugared for
expression:
val finalResult = f(100).flatMap { fResult =>
--------
That code shows that flatMap
is invoked after f
is applied to the value 100
.
To understand the debug output, it helps to look at flatMap
:
def flatMap(fip: Int => Debuggable): Debuggable = {
println("\n>>>> entered fmap >>>>")
println(s"fmap: value: ${this.value}")
println(s"fmap: msg: (${this.msg})")
val nextValue = fip(value)
println(s"fmap: msg: (${this.msg})")
println(s"fmap: next val: ${nextValue.value}")
println(s"fmap: next msg: \n(${nextValue.msg})")
println("<<<< leaving fmap <<<<\n")
Debuggable(nextValue.value, msg + "\n" + nextValue.msg)
}
The debug output shows that this.value
in flatMap
is 200
. This was passed into the new Debuggable
by f
. The this.msg
value is also provided by f
when it creates the new Debuggable
.
At this point there’s no more output from flatMap
. What happens is that this line of code in flatMap
is invoked:
val nextValue = fun(value)
That line causes this output to be printed:
[g: a = 200]
We’re now at this point in the desugared for
expression:
val finalResult = f(100).flatMap { fResult =>
g(fResult)
----------
What happens in g
The output [g: a = 200]
shows that the g
function is entered. Here’s g
’s source code:
def g(a: Int): Debuggable = {
println(s"\n[g: a = $a]")
val result = a * 3
Debuggable(result, s"g: input: $a, result: $result")
}
As you saw with f
, what happens here is:
g
’sprintln
statement produces the output shown- a new
result
is calculated g
creates a newDebuggable
instance as its last line
The second flatMap
call is reached
When g
returns the new Debuggable
instance, the second flatMap
call is invoked, which you can see in the desugared code:
val finalResult = f(100).flatMap { fResult =>
g(fResult).flatMap { gResult => ...
--------
flatMap
is invoked with values given to it by g
, as shown in its output:
>>>> entered fmap >>>>
fmap: value: 600
fmap: msg: (g: input: 200, result: 600)
g
multiplied the value it was given (200) to produce a new value, 600
, along with the message shown.
Those lines of debug output are produced by these first three lines of code in flatMap
:
println("\n>>>> entered fmap >>>>")
println(s"fmap: value: ${this.value}")
println(s"fmap: msg: (${this.msg})")
After that, flatMap
’s fourth line of code is reached:
val nextValue = fun(value)
That line of code causes the next line of output to be produced:
[h: a = 600]
This tells us that the h
function was just entered.
Note: The flatMap
calls haven’t returned yet
I’ll come back to the h
function call in a moment, but first, it’s important to note that the two flatMap
function calls haven’t returned yet. They both pause when that fourth line of code is reached.
You can understand that by a) looking at flatMap
’s source code, and b) looking at the desugared for
expression:
val finalResult = f(100).flatMap { fResult =>
g(fResult).flatMap { gResult => // YOU ARE NOW HERE
h(gResult).map { hResult =>
hResult
}
}
}
The flatMap
calls haven’t returned yet because:
- The first
flatMap
function reaches its fourth line, which invokesg
- When
g
finishes, the secondflatMap
call is invoked - When that instance of
flatMap
reaches its fourth line,h
is invoked - As you’re about to see, when
h
finishes running,map
is invoked
h
executes
Getting back to where I was ... the last line of debug output was this:
[h: a = 600]
This tells you that the h
function was just invoked. h
prints that output, doubles the value it receives, then creates a new Debuggable
instance with that new value and new message:
def h(a: Int): Debuggable = {
println(s"\n[h: a = $a]")
val result = a * 4
Debuggable(result, s"h: input: $a, result: $result")
}
map
is entered
As the desugared for
expression shows, after h
runs, map
is invoked:
val finalResult = f(100).flatMap { fResult =>
g(fResult).flatMap { gResult =>
h(gResult).map { hResult => ...
The map
call produces the next debug output you see:
>>>> entered map >>>>
map: value: 2400
map: msg: (h: input: 600, result: 2400)
map: nextValue: 2400
<<<< leaving map <<<<
This shows that map
is called, it runs (producing this output), and then it exits. This confirms what I wrote in the previous lesson:
In a
for
expression,map
is the last function called and the first to exit.
Here’s a look at map
’s source code, followed by its output:
def map(f: Int => Int): Debuggable = {
println("\n>>>> entered map >>>>")
println(s"map: value: ${value}")
println(s"map: msg: (${msg})")
val nextValue = f(value) //Int
// there is no `nextValue.msg`
println(s"map: nextValue: ${nextValue}")
println("<<<< leaving map <<<<\n")
Debuggable(nextValue, msg)
}
>>>> entered map >>>>
map: value: 2400
map: msg: (h: input: 600, result: 2400)
map: nextValue: 2400
<<<< leaving map <<<<
The flatMap
calls unwind
After the map
method finishes, the next thing that happens is that the flatMap
calls begin to unwind. A look at the de-sugared code reminds you why this unwinding happens now:
val finalResult = f(100).flatMap { fResult =>
g(fResult).flatMap { gResult =>
h(gResult).map { hResult => // map RETURNS
hResult // CONTROL TO flatMap
}
}
}
What’s happened so far is:
f
was called- When
f
finished, the firstflatMap
function was called - It called
g
- When
g
finished, the secondflatMap
function was called - It called
h
- When
h
finished,map
was called map
just finished running
Now that map
is finished, flow of control returns to the second flatMap
invocation. This is the output from it:
fmap: msg: (g: input: 200, result: 600)
fmap: next val: 2400
fmap: next msg:
(h: input: 600, result: 2400)
<<<< leaving fmap <<<<
When that flatMap
call finishes running, flow of control returns to the first flatMap
invocation, which produces this output:
fmap: msg: (f: input: 100, result: 200)
fmap: next val: 2400
fmap: next msg:
(g: input: 200, result: 600
h: input: 600, result: 2400)
<<<< leaving fmap <<<<
When it finishes, the final output from the application is shown. This is what the println
statements look like at the end of the App
:
println("\n----- FINAL RESULT -----")
println(s"final value: ${finalResult.value}")
println(s"final msg: \n${finalResult.msg}")
and this is what their output looks like:
----- FINAL RESULT -----
final value: 2400
final msg:
f: input: 100, result: 200
g: input: 200, result: 600
h: input: 600, result: 2400
Summary
If you had any confusion about how for
expressions run — especially with wrapper classes like Debuggable
— I hope this lesson is helpful. I encourage you to work with the source code and modify it until you’re completely comfortable with how it works. The lessons that follow will continue to build on this knowledge.
Update: All of my new videos are now on
LearnScala.dev