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 themap
andflatMap
methods (likeSequence
); it just needs to return a type that implementsmap
andflatMap
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 anInt
- After doing whatever it does,
map
returns aWrapper[Int]
, i.e., anInt
inside a newWrapper
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 anInt
- After doing whatever it does,
map
returns aDebuggable
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 aWrapper[Int]
- After doing whatever it does,
flatMap
returns aWrapper[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 aDebuggable
instance - After doing whatever it does,
flatMap
returns aDebuggable
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 thenewValue
created in the first line - Appending
newValue.message
to themessage
that was passed into the currentDebuggable
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 by2
g
multipled its input value by3
h
multipled its input value by4
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.
Update: All of my new videos are now on
LearnScala.dev