home search about rss feed twitter ko-fi

Creating A ‘Debuggable’ Class (Scala 3 Video)

A few lessons I asked that if you had three functions with these signatures:

def f(a: Int): (Int, String)
def g(a: Int): (Int, String)
def h(a: Int): (Int, String)

wouldn’t it be cool if you could somehow use those functions in a for expression, like this:

val finalResult = for {
    fResult <- f(100)
    gResult <- g(fResult)
    hResult <- h(gResult)
} yield hResult

Now that I‘ve covered the “wrapper” concept, in this lesson I’ll show exactly how to do that.

A big observation

At some point in the history of Scala, someone made a few observations that made this solution possible. The key insight may have been this:

For something to be used in a for expression, it doesn’t have to be a class that implements the map and flatMap methods (like Sequence); it just needs to return a type that implements map and flatMap methods.

That’s a big observation, or at least it was for me. So far in this book I’ve shown classes like Sequence and Wrapper that implement map and flatMap so they can be used in for, but the reality is that any function that returns such a type can be used in for.

The next conceptual leap

With that observation in hand, it’s a small conceptual leap to figure out how to get these functions to work in for:

def f(a: Int): (Int, String)
def g(a: Int): (Int, String)
def h(a: Int): (Int, String)

Can you see the solution?

Instead of these functions returning a tuple, they could return ... something else ... a type that implements map and flatMap.

(I’ll pause here to let that sink in.)

The type these functions return should be something like a Wrapper, but where Wrapper contained a single value — such as a Wrapper[Int] — this type should be a wrapper around two values, an Int and a String. If such a type existed, f, g, and h could return their Int and String values wrapped in that type rather than a tuple.

You could call it a TwoElementWrapper:

def f(a: Int): TwoElementWrapper(Int, String)
def g(a: Int): TwoElementWrapper(Int, String)
def h(a: Int): TwoElementWrapper(Int, String)

But that’s not very elegant. When you think about it, the purpose of f, g, and h is to show how functions can return “debug” information in addition to their primary return value, so a slightly more accurate name is Debuggable:

def f(a: Int): Debuggable(Int, String)
def g(a: Int): Debuggable(Int, String)
def h(a: Int): Debuggable(Int, String)

If Debuggable implements map and flatMap, this design will let f, g, and h be used in a for expression. Now all that’s left to do is to create Debuggable.

Because Debuggable is going to work like Wrapper, I’m going to try to start with the Wrapper code from the previous lesson, and see if I can modify it to work as needed.

Trimming Wrapper down

The first thing I want to do is to convert Wrapper into a case class to simplify it. When I do that, and then rename it to Debuggable, I get this:

case class Debuggable[A] (value: A) {
    def map[B](f: A => B): Debuggable[B] = {
        val newValue = f(value)
        new Debuggable(newValue)
    }
    def flatMap[B](f: A => Debuggable[B]): Debuggable[B] = {
        val newValue = f(value)
        newValue
    }
}

That’s a start, but as this code shows:

def f(a: Int): Debuggable(Int, String)

Debuggable must take two input parameters.

Ignoring generic types for a few moments, this tells me that Debuggable’s signature must look like this:

case class Debuggable (value: Int, message: String) {

When I further remove the generic types and bodies from the map and flatMap methods, Debuggable becomes this:

case class Debuggable (value: Int, message: String) {
    def map(f: Int => Int): Debuggable = ???
    def flatMap(f: Int => Debuggable): Debuggable = ???
}

Now I just need to think about the logic for the map and flatMap methods.

A reminder of how for translates

An important thing to know at this point is that when you write a for expression like this:

    val seq = Sequence(1,2,3)
    for {
        i <- seq
        j <- seq
        k <- seq
        l <- seq
    }

it will be translated into three flatMap calls followed by one map call:

seq.flatMap { i => 
    seq.flatMap { j => 
        seq.flatMap { k => 
            seq.map { l => 
                i.$plus(j).$plus(k).$plus(l)
            }
        }
    }
}

For what we’re about to do, the most important thing to observe is that map is a) the last function called, and also b) the first one that returns.

You can think of the flatMap calls as being a series of recursive calls. flatMap keeps calling itself until the final expression is reached, at which point map is called. When map is called, it executes, returns its result, and then the flatMap calls unwind.

With that observation in hand, let’s implement the map and flatMap methods for the Debuggable class.

map

When I first created the IntWrapper class I showed its map signature:

def map(f: Int => Int): Wrapper[Int] = ???

and then wrote that you could say these things about map‘s implementation:

  • It takes a function that transforms an Int to an Int
  • After doing whatever it does, map returns a Wrapper[Int], i.e., an Int inside a new Wrapper

Similarly, when you look at map’s signature in Debuggable:

def map(f: Int => Int): Debuggable = ???

you can say these things about it:

  • It takes a function that transforms an Int to an Int
  • After doing whatever it does, map returns a Debuggable instance

As I did with IntWrapper, I write those sentences as comments inside map like this:

def map(f: Int => Int): Debuggable = {
    // apply `f` to an `Int` to get a new `Int`
    // wrap that result in a `Debuggable`
}

I can implement the first sentence as I did in Wrapper — by applying the function f to the value in Debuggable — to get this:

def map(f: Int => Int): Debuggable = {

    // apply `f` to an `Int` to get a new `Int`
    val newValue = f(value)

    // wrap that result in a `Debuggable`
}

Now map just needs to return a new Debuggable instance.

The Debuggable constructor takes an Int and a String, which I named value and message. The value map should return is the new, transformed value I got by applying f to value, so that part is good.

While it may not be immediately clear why I should return the message from the Debuggable constructor, it’s the only String message I have, so I’ll use it to construct and return a new Debuggable instance:

def map(f: Int => Int): Debuggable = {
    val newValue = f(value)
    Debuggable(newValue, message)
}

This image shows how the value and message from the Debuggable class relate to how they are used in map:

(This image is shown in the video.)

When I write Scala/FP functions that use constructor parameters I often like to put a this reference in front of those values so I know where they came from. That change leads to this code:

def map(f: Int => Int): Debuggable = {
    val newValue = f(this.value)
    Debuggable(newValue, this.message)
}

That’s the complete map function.

Why map returns message

The short answer of “why” message is returned here is because map is the “last function called and the first to return.” As you’ll see in the details in the next lesson, map creates the first message in the stack of messages created in the for expression.

flatMap

When I first created the Wrapper class I wrote that you could say these things about its flatMap implementation:

  • It takes a function that transforms an Int to a Wrapper[Int]
  • After doing whatever it does, flatMap returns a Wrapper[Int]

That led to this code, which was a direct implementation of those statements:

def flatMap[B](f: A => Wrapper[B]): Wrapper[B] = {
    val newValue = f(value)
    newValue
}

For the debuggable class you can say these things about its flatMap implementation:

  • It takes a function that transforms an Int to a Debuggable instance
  • After doing whatever it does, flatMap returns a Debuggable instance

Writing flatMap with those statements as comments looks like this:

def flatMap(f: Int => Debuggable): Debuggable = {
    // (1) apply the function to an Int to get a new Debuggable
    // (2) return a new Debuggable instance based on the new value
}

As with the Wrapper class, for the first comment the question is, “What Int should I apply the function to?” As before, the answer is, “Apply it to the value you hold.” This leads to this code:

def flatMap(f: Int => Debuggable): Debuggable = {

    // (1) apply the function to an Int to get a new Debuggable
    val newValue: Debuggable = f(value)

    // (2) return a new Debuggable instance based on the new value

}

So far this is the same as the Wrapper class. Now let’s get the second line working.

The second comment states:

// return a new Debuggable instance based on the new value

This part was a little tricky for me, and the only way I could understand the solution was by really digging into the code (as shown in the next lesson). The short answer is that you need to create the new Debuggable instance from:

  • The Int of the newValue created in the first line
  • Appending newValue.message to the message that was passed into the current Debuggable instance

I’ll explain this a little more shortly, but the short answer is that this is the solution:

Debuggable(nextValue.value, message + nextValue.message)

How to think about that

You can think about that solution as follows. Imagine that you have a for expression with a series of Debuggable instances, like this:

val finalResult = for {
    fResult <- f(100)
    gResult <- g(fResult)
    hResult <- h(gResult)
} yield hResult

Each of those lines — f(100), g(fResult), etc. — are going to add a new message to the overall string of messages. Therefore, at some point you need to string those messages together, and flatMap is the place where that happens. The most recent message is contained in newValue.message, and all of the previous messages are contained in the message that is passed into Debuggable (i.e., this.message).

I explain this in great detail in the next lesson, so if you want to completely understand how this works, go ahead and look at that lesson at this point.

Finishing flatMap

Finishing up with flatMap, I now know what the second expression looks like, so I add that to flatMap’s body:

def flatMap(f: Int => Debuggable): Debuggable = {

    // (1) apply the function to an Int to get a new Debuggable
    val newValue: Debuggable = f(value)

    // (2) return a new Debuggable instance based on the new value
    Debuggable(newValue.value, message + newValue.message)

}

The completed class

When I add map and flatMap to the Debuggable class definition, I now have this code:

case class Debuggable (value: Int, message: String) {
    def map(f: Int => Int): Debuggable = {
        val newValue = f(value)
        Debuggable(newValue, message)
    }
    def flatMap(f: Int => Debuggable): Debuggable = {
        val newValue: Debuggable = f(value)
        Debuggable(newValue.value, message + "\n" + newValue.message)
    }
}

Now I just need to figure out how to write the f, g, and h functions so I can write a for expression like this:

val finalResult = for {
    fResult <- f(100)
    gResult <- g(fResult)
    hResult <- h(gResult)
} yield hResult

Writing f, g, and h

As I mentioned earlier, the solution to getting f, g, and h working in a for expression is that they should return a Debuggable instance. More specifically they should:

  • Take an Int input parameter
  • Return a Debuggable instance

This tells me that their signatures will look like this:

def f(a: Int): Debuggable = ???
def g(a: Int): Debuggable = ???
def h(a: Int): Debuggable = ???

In the previous lessons these functions worked like this:

  • f multipled its input value by 2
  • g multipled its input value by 3
  • h multipled its input value by 4

Thinking only about f, I can begin to write it like this:

def f(a: Int): Debuggable = {
    val result = a * 2
    val message = s"f: a ($a) * 2 = $result."
    ???
}

This gives me a result and message similar to what I had before, but now the function signature tells me that I need to return those values as a Debuggable instance rather than as a tuple. That’s a simple step:

def f(a: Int): Debuggable = {
    val result = a * 2
    val message = s"f: a ($a) * 2 = $result."
    Debuggable(result, message)
}

f now takes an Int input parameter, and yields a Debuggable, as desired.

A test App

g and h are simple variations of f, so when I create them and put all of the functions in a test App along with the original for expression, I get this:

object Test extends App {

    val finalResult = for {
        fResult <- f(100)
        gResult <- g(fResult)
        hResult <- h(gResult)
    } yield hResult

    // added a few "\n" to make the output easier
    // to read
    println(s"value:   ${finalResult.value}\n")
    println(s"message: \n${finalResult.message}")

    def f(a: Int): Debuggable = {
        val result = a * 2
        val message = s"f: a ($a) * 2 = $result."
        Debuggable(result, message)
    }

    def g(a: Int): Debuggable = {
        val result = a * 3
        val message = s"g: a ($a) * 3 = $result."
        Debuggable(result, message)
    }

    def h(a: Int): Debuggable = {
        val result = a * 4
        val message = s"h: a ($a) * 4 = $result."
        Debuggable(result, message)
    }
}

Running that App yields this output:

value:   2400

message: 
f: a (100) * 2 = 200.
g: a (200) * 3 = 600.
h: a (600) * 4 = 2400.

While that output may not be beautiful for many people, if this is the first time you’ve enabled something like this in a for expression it can be a really beautiful thing.

More information

I went through some parts of this lesson a little quicker than usual. My thinking is that if you’re comfortable with how for, map, and flatMap work, I didn’t want to go too slow. But for people like me who struggle with this concept, I want to cover this topic more deeply. Therefore, the next lesson takes a deep dive into exactly how all of this code works.