Change shadowing mechanism of extension methods for on par implicit class behavior

We want in the future to remove implicit classes in favor of extension methods, but they have some unique behaviors that extension methods fail to imitate currently. One of these is the shadowing mechanism that affects extension methods like regular methods, but does not affect implicit classes because their behavior is derived from implicit conversions.
Example:

import scala.annotation.targetName
trait Foo[+T]

object Ops:
  extension (foo : Foo[Int])
    @targetName("bazInt")
    def baz : Unit = {}

import Ops.*

extension (foo : Foo[Double])
  @targetName("bazDouble")
  def baz : Unit = {}

val f = new Foo[Int] {}
f.baz //error

The error is:

value baz is not a member of Playground.Foo[Int].
An extension method was tried, but could not be fully constructed:

    Playground.baz(f)    failed with

        Found:    (Playground.f : Playground.Foo[Int])
        Required: Playground.Foo[Double]

In Scala 3 the name shadowing mechanism was disabled for implicits. I think it should be the same for extension methods, not only to match the implicit class behavior, but also because I believe it’s the more intuitive approach. It does not make sense to me that if I import a library that implements a + operator for a type-class via an extension method, then I cannot define my own + for a more specific type-class within the same namespace without having the library + for fallback/different behavior when I need it.

I’m not the only one. Past issues (#14777, #12680) show that others encountered the same problem, suggesting the same intuition.

I don’t know how many libraries were completely converted to Scala 3, but I think that it is likely we haven’t seen enough noise about this since many still use implicit classes for cross-compilation. If this behavior can be fixed, we should do so promptly, because it can lead to breaking changes as more code bases rely on extension methods.

2 Likes

It definitely seems odd for the compiler to even attempt applying an extension method that doesn’t match the type of the receiver. But I guess the resolution algorithm tries any extension with matching name.

Extension methods are really normal methods, so this behavior is not surprising. Here is the example reformulated with normal method surface syntax:

import scala.annotation.targetName
trait Foo[+T]

object Ops:
    @targetName("bazInt")
    def baz(foo : Foo[Int]) : Unit = {}

import Ops.*

@targetName("bazDouble")
def baz(foo : Foo[Double]) : Unit = {}

val f = new Foo[Int] {}
val _ = baz(f) //error

You get basically the same error:

-- [E007] Type Mismatch Error: test.scala:14:12 --------------------------------
14 |val _ = baz(f) //error
   |            ^
   |            Found:    (f : Foo[Int])
   |            Required: Foo[Double]
   |
   | longer explanation available when compiling with `-explain`
1 error found

So none of this is unexpected. It’s what extension methods are. Tweaking that behavior would be dubious at best.

From user perspective that’s an implementation detail and not a language detail, because extension methods are meant to replace the language feature called implicit classes. Implicit classes work for the example I gave above. How do you see we can replace implicit classes if nothing covers their current function?

Although such behaviour may be anticipated from the language designers point of view, it still raises my eyebrow as non-guru regular user because all of these examples (where the methods are defined on equal footing) compile just fine:

  1. direct methods (scastie):
import scala.annotation.targetName
trait Foo[+T]

@targetName("bazInt")
def baz(foo : Foo[Int]) : Unit = {}

@targetName("bazDouble")
def baz(foo : Foo[Double]) : Unit = {}

val f = new Foo[Int] {}
val _ = baz(f) //works!
  1. methods via extensions (scastie):
import scala.annotation.targetName
trait Foo[+T]

extension (foo : Foo[Int])
  @targetName("bazInt")
  def baz : Unit = {}

extension (foo : Foo[Double])
  @targetName("bazDouble")
  def baz : Unit = {}

val f = new Foo[Int] {}
f.baz //works!
  1. imported methods via extensions (scastie):
import scala.annotation.targetName
trait Foo[+T]

object Ops:
  extension (foo : Foo[Int])
    @targetName("bazInt")
    def baz : Unit = {}

  extension (foo : Foo[Double])
    @targetName("bazDouble")
    def baz : Unit = {}

import Ops.*
val f = new Foo[Int] {}
f.baz //works!

It’s only when you mix the two the compiler says: ‘no’.

3 Likes

Shadowing is useful to remove the surprise factor from global definitions that get imported into the scope. But extensions are intuitively considered relational (to a specific type) and not global.

See also:

And the reference therein: