Search code examples
scalaimplicitcontravariance

Scala: passing a contravariant type as an implicit parameter does not choose the nearest supertype?


Why does the following code does not pick up the implicit val with the nearest supertype?

class A
class B extends A

trait TC[-T] { def show(t: T): String }

implicit val showA = new TC[A] { def show(a: A): String = "it's A" }
implicit val showB = new TC[B] { def show(b: B): String = "it's B" }

def doit[X](x: X)(implicit tc: TC[X]): Unit = println(tc.show(x))

doit(new A) // "it's A" as expected
doit(new B) // "it's A" ... why does this not give "it's B" ???

If you make TC invariant (i.e., trait TC[T] (...)), then it works fine and doit(new B) returns "it's B" as expected.

By adding another implicit for type Any, this issue is even extremer:

class A
class B extends A

trait TC[-T] { def show(t: T): String }

implicit val showA = new TC[A] { def show(a: A): String = "it's A" }
implicit val showB = new TC[B] { def show(b: B): String = "it's B" }
implicit val showAny = new TC[Any] { def show(x: Any): String = "it's Any" }

def doit[X](x: X)(implicit tc: TC[X]): Unit = println(tc.show(x))

doit(new A) // "it's Any" ... why does this not give "it's A" ???
doit(new B) // "it's Any" ... why does this not give "it's B" ???

And again it work's fine if TC is invariant.

What's going on here, and how to solve it? My goal is to have a contravariant TC that implicitly chooses the nearest suitable supertype.


Solution

  • Since TC[-T] is contravariant in its type argument, TC[A] is a subtype of TC[B], and is therefore considered to be more "specific". That's a well known (and somewhat controversial) design decision, which essentially means that implicit resolution with contravariance sometimes behaves quite unexpectedly.


    Workaround 1: prioritizing implicits using inheritance

    Here is how you could use the inheritance and the "LowPriority-*-Implicits" pattern:

    class A
    class B extends A
    class C extends B
    class D extends C
    
    trait TC[-T] { def show(t: T): String }
    
    trait LowPriorityFallbackImplicits {
      implicit def showA[X <: A]: TC[X] = 
        new TC[A] { def show(a: A): String = "it's A" }
    }
    object TcImplicits extends LowPriorityFallbackImplicits {
      implicit def showC[X <: C]: TC[X] = 
        new TC[C] { def show(c: C): String = "it's C" }
    }
    
    def doit[X](x: X)(implicit tc: TC[X]): Unit = println(tc.show(x))
    
    import TcImplicits._
    
    doit(new A)
    doit(new B)
    doit(new C)
    doit(new D)
    

    Now it picks the most specific one in all cases:

    it's A
    it's A
    it's C
    it's C
    

    Workaround 2: invariant helper trait

    Here is how you can forcibly swap the implicits in your particular example by introducing a helper trait that is invariant in the type argument:

    class A
    class B extends A
    
    trait TC[-T] { def show(t: T): String }
    
    val showA = new TC[A] { def show(a: A): String = "it's A" }
    val showB = new TC[B] { def show(b: B): String = "it's B" }
    
    trait TcImplicit[X] { def get: TC[X] }
    implicit val showAImplicit = new TcImplicit[A] { def get = showA }
    implicit val showBImplicit = new TcImplicit[B] { def get = showB }
    
    def doit[X](x: X)(implicit tc: TcImplicit[X]): Unit = println(tc.get.show(x))
    
    doit(new A)
    doit(new B)
    

    prints

    it's A
    it's B