Overload methods with parameter lists
Have you ever wondered if you can enrich a method that you need to implement in a class to get more information from the call site?
For example, let's say you have method debug
in a logger
interface AbstractLogger
. Can we implement the logger
interface and at the same time overload debug
with another
version that takes more parameters every time the users of our API call
debug
?
In fact, can we do this without breaking binary compatibility in the
interface that defines debug
and ensuring that the users of
our API can only call the enriched method?
I asked myself this question three days ago and I came up with a solution that I think it's worth a short explanation in this blog post. My use case was triggered by a feature I wanted to add to bloop (a fast compilation server for Scala).
What is the Problem?
It's always amazed me how verbose the debug log level can be under
tools such as sbt. This verbosity typically stands in the way of finding
the cause for a resolution or compilation misbehavior. Running
debug
on the sbt shell would dump more than 20.000 logs in
my screen, enough to overflow my terminal buffer and lose potentially
important debug logs on the way.
I've found myself often in this scenario. It feels like you're trying to find a needle in a haystack. It can be better if you're lucky enough to know the shape of the debug messages you're after (you can grep), but this is rarely the case.
I wanted bloop users to have a better time debugging a compilation or
testing issues by narrowing down the scope of the debug logs with
filters. bloop test my-app --debug test
would only dump
debug logs related to the test task, instead of all the other debug
messages in unrelated tasks.
The logging infrastructure in bloop implements several
third-party Logger
interfaces and aggregates them in an
abstract class BloopLogger
(for simplicity we'll extend
only one: AbstractLogger
).
// A third-party logger interface (in our classpath)
abstract class AbstractLogger {
def debug(msg: String): Unit
def info(msg: String): Unit
def error(msg: String): Unit
}
// The logger interface that we use in all the bloop APIs
abstract class BloopLogger extends AbstractLogger
// One simple implementation of a bloop logger
class SimpleLogger extends BloopLogger {
override def debug(msg: String): Unit = println(s"Debug: $msg")
override def info(msg: String): Unit = println(s"Info: $msg")
override def error(msg: String): Unit = println(s"Error: $msg")
}
We'd like to add an enriched version of debug
that looks
like debug(msg: String)(implicit ctx: DebugContext)
, where
DebugContext
identifies the context where
debug
is called. (We decide to make the parameter implicit,
but there's no reason why you shouldn't be able to make it
explicit.)
Third-party logging APIs are frozen and cannot be modified, so we
cannot change the original debug
method signature in
AbstractLogger
.
Besides, we don't want to add an special method
debugWithFilter
that we would need to teach to all Bloop
project contributors. We would spend a lot of time telling contributors
that they must not use the normal debug
, but
debugWithFilter
because bla.
What we really want is to shadow the original
debug
method with the "enriched"
debug
method so that only the latter can be used by
default.
So, wrapping up, we don't only want to overload a method, but also shadow it, and we want to do that without changing the public API of the interfaces we implement. It looks hard but let's persevere.
How do we go about implementing this feature?
A First Approach
We know beforehand the compiler will tell us there's some kind of
ambiguity between the two debug
methods, but bear with me
and let's write the simplest possible solution: let's add the new
debug
method in SimpleLogger
and then use it
in a small main
method.
// The debug context that we want to pass implicitly
sealed trait DebugContext
object DebugContext {
case object Ctx1 extends DebugContext
case object Ctx2 extends DebugContext
}
// The logger interface that we use in all the bloop APIs
abstract class BloopLogger extends AbstractLogger {
def debug(msg: String)(implicit ctx: DebugContext): Unit
}
// One simple implementation of a bloop logger
final class SimpleLogger extends BloopLogger {
override def debug(msg: String): Unit = println(s"Debug: $msg")
override def info(msg: String): Unit = println(s"Info: $msg")
override def error(msg: String): Unit = println(s"Error: $msg")
override def debug(msg: String)(implicit ctx: DebugContext): Unit =
println(s"Debug: $msg")
}
// The application that is the use site of our logger API
object MyApp {
def main(args: Array[String]): Unit = {
val logger = new SimpleLogger
.debug("This is a debug message")
logger}
}
When we compile the above code, the compiler emits the following error:
ambiguous reference to overloaded definition,
both method debug in class SimpleLogger of type (msg: String)(implicit ctx: DebugContext)Unit
and method debug in class SimpleLogger of type (msg: String)Unit
match argument types (String)
(Scastie link to runnable code here.)
Can we work around this ambiguous reference? Let's consider all our possibilities.
If we try to change the call-site to select the most specific debug
method
(logger.debug("This is a debug message")(DebugContext.Ctx1)
),
the error persists.
If we try to move the new debug definition to the implementation
class, then it won't be usable by APIs using BloopLogger
,
which the rest of our codebase does because we have several
implementations.
It looks like everything is lost. But this is the moment when knowing or intuiting that the ambiguity checker inside the compiler relies on the linearization order saves your day.
A Solution that Relies on Class Linearization
First off, what's the linearization order? There are a few good resources in Internet, such as this one, that explain it well. But let me oversimplify and say that you can think of the linearization order as the order with which Scala will initialize an instance of a given class and all its super classes (or traits).
When Scala looks up the definition of a member, it relies on the linearization order to pick the first unambiguous candidate. A quick example:
trait A
trait B extends A { def foo: String }
trait C
class D extends C with B
// The linearization order to find `foo` is `D -> C -> B`
(new D).foo
The same procedure happens when Scala checks for ambiguous references
and emits errors such as the ones we got before. As this example
illustrates, the compiler will not exhaustively look for definitions of
foo
in all transitive super classes, it stops at the first
search hit.
This insight means that we can modify our previous example such that
our enriched debug
method is always found first. This way,
we dodge the ambiguous reference to overloaded debug
s.
// We make `DebugLogger` private at the logging package level to avoid undesired users
private[logging] abstract class DebugLogger extends AbstractLogger {
protected def printDebug(msg: String): Unit
override def debug(msg: String): Unit = printDebug(msg)
}
// The logger interface that we use in all the bloop APIs
abstract class BloopLogger extends DebugLogger {
def debug(msg: String)(implicit ctx: DebugContext): Unit
}
// One simple implementation of a bloop logger
final class SimpleLogger extends BloopLogger {
override def info(msg: String): Unit = println(s"Info: $msg")
override def error(msg: String): Unit = println(s"Error: $msg")
override protected def printDebug(msg: String): Unit =
println(s"Debug: $msg")
override def debug(msg: String)(implicit ctx: DebugContext): Unit =
printDebug(s"$msg ($ctx)")
}
The trick to make the previous code work is defining the
implementation of the simple debug
method in
DebugLogger
and making BloopLogger
extend
DebugLogger
. We have introduced a printDebug
to avoid inter-dependencies between the two debug
methods,
as they will cause other reference errors.
Once we have defined the method we want to shadow in a super class of
the class we want to support in our API (in this case
BloopLogger
), the logger implementations only need to
define the enriched debug
method.
Users of this API will not be able to call the
simple debug
unless they do an upcast to
the third-party logger AbstractLogger
. This is intended --
the goal is to have a good default, not to make it completely impossible
to call the simple debug, so make sure that it still has a sensible
implementation.
In Bloop's case, the third-party logger API is never exposed and I recommend doing so in your application or library if you can.
With the above code, compiling our simple MyApp
fails
compilation with a
could not find implicit value for parameter ctx: DebugContext
,
which confirms us that Scala is successfully selecting the right
method.
We can fix it by passing the context either implicitly or explicitly.
object MyApp {
def main(args: Array[String]): Unit = {
println(
"Running demo application for https://jorge.vican.me/post/overload-methods-with-more-parameter-lists/")
implicit val ctx: DebugContext = DebugContext.Ctx1
val logger = new SimpleLogger
.debug("This is a debug message")
logger}
}
Complete Scastie Example
Conclusion
Overloading a method inherited from a third-party class is possible in Scala. It requires a little bit of gymnastics, but once we're familiar with the technique we can apply it in many other scenarios.
The same technique works with new explicit parameters (instead of implicit parameters). They key point is that we can overload methods by adding extra parameter lists to their definition, playing with the linearization order and defining the methods in the right place.