home search about rss feed twitter ko-fi

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 the value and msg that map and flatMap have before they apply their functions
  • I added println statements to show the nextValue that map and flatMap have after they apply their functions, and also show the new message that’s created when flatMap 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’s println statement produces the output shown
  • a new result is calculated
  • g creates a new Debuggable 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 invokes g
  • When g finishes, the second flatMap 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 first flatMap function was called
  • It called g
  • When g finished, the second flatMap 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