Back

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
    logger.debug("This is a debug message")
  }
}

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 debugs.

// 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
    logger.debug("This is a debug message")
  }
}

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.