Updated Proposal: Revisiting Implicits

I think there are indeed some potential issues about when extension methods are in scope and when they aren’t. But the issue here is that there is no parameterized cast method.

The following does work (https://scastie.scala-lang.org/9Q4UB9WDT7WPJ1StfwfGoA)

import Convertible.given
val x: String = 5.cast
1 Like

I’m not sure what you refer to as “import tax”. Is it because you have to import ops? You could just get rid of the ops objects and place the extensions (ops, lifts, syntax) directly inside the companion objects of the type classes:

trait Convertible[A,B] {
  def (a: A) cast: B
}

object Convertible {
  def apply[A,B](given C: Convertible[A,B]): Convertible[A,B] = C

  given Convertible[Int, String] {
    def (a: Int) cast: String = s"<$a>"
  }
  
  given ops[A]: AnyRef {
    def[B](a: A) cast (given C: Convertible[A,B]): B = C.cast(a)
  }
}

object Test extends App {
  import Convertible.given
  val s1 = 1.cast[String]
  val s2: String = 2.cast
}

This is the same as @Jasper-M 's xample.

IMHO: It is a usual point of puzzlement in a case where such imports cannot be done globally.

It is very often when companion objects of type classes are just not available for modification.
That is why I think scala should have some sort of scope injection.

1 Like

You mean something like this?

object external {
  trait Convertible[A,B] {
    def (a: A) cast: B
  }

  object Convertible {
    def apply[A,B](given C: Convertible[A,B]): Convertible[A,B] = C

    given Convertible[Int, String] {
      def (a: Int) cast: String = s"<$a>"
    }
  }
}

object ConvertibleOps {
  import external.Convertible

  given ops[A]: AnyRef {
    def[B](a: A) cast (given C: Convertible[A,B]): B = C.cast(a)
  }
}

object Test extends App {
  import ConvertibleOps.given
  
  val s1 = 1.cast[String]
  val s2: String = 2.cast
}

This actually seem to work, even though I didn’t expect the type instance to be propagated from the scope of ConvertibleOps to the one of Test.

Nevertheless I will follow martin’s advice, and will avoid such templates if it is possible.

This is not the surprising part and not what’s going on here. Try this instead:

object Test extends App {
  import ConvertibleOps.given
  // error - "Not found: type Convertible"
  type X = Convertible
}

Which is not different than how implicits work nowadays:

object external {
  trait Convertible[A,B] {
    def cast(a: A): B
  }

  object Convertible {
    implicit object IntToString extends Convertible[Int, String] {
      def cast(a: Int): String = s"<$a>"
    }
  }
}

object ConvertibleOps {
  import external.Convertible

  implicit class Ops[A](a: A) {
    def cast[B]()(implicit c: Convertible[A,B]): B = c.cast(a)
  }
}

object Test extends App {
  import ConvertibleOps
  
  // compiles
  val s1 = 1.cast[String]
  val s2: String = 2.cast

  // doesn't
  type X = Convertible
  val x = IntToString
}

Which is one of the reasons why implicits are confusing. They do not only get applied in the local scope upon import – a name resolution mechanism – but are being propagated between scopes, unlike names.

I have compiled it in dottyVersion “0.21.0-RC1”
(Original source code just cannot be compiled it seems it is misprint )
It can be compiled with

import ConvertibleOps.given

or

import ConvertibleOps._

Actually I do not understand why implicit conversion can be imported via simple import or via given import. It seems unlogical.
It is a big surprise for me that

import ConvertibleOps._

can also be compiled.

I really don’t know why my code does not without an expected type, and works with one. The available implicit instances are not ambiguous AFAICT (ping @smarter).

That’s the current Scala 2 syntax, which (in this scenario) is equivalent to Dotty’s import COps.given.

The following actually behaves in a non-surprising way (Scala 2);

object a {
  implicit val x = 1
}

object b {
  import a._
  def foo()(implicit i: Int) = i
  def bar() = foo()
}

object c {
  b.foo() // doesn't compile
  b.bar() // compiles
}

Yet this behaves surprisingly:

object a {
  trait TypeClass[A] {
    def foo(): String
  }
  object TypeClass {
    implicit object IntTC extends TypeClass[Int] { def foo(): String = "int" }
  }
}

object b {
  import a._
  def foo[A](a: A)(implicit tc: TypeClass[A]) = tc.foo()
  def bar() = foo(1)
}

object c {
  b.foo(42) // compiles (surprising)
  b.bar() // compiles
}

If you believe you’ve found an issue in the implementation, please report it on github.com/lampepfl/dotty/issues, it’s impossible to keep track of these things properly in a thread like this.

3 Likes

What are you referring to? The “surprising” behavior discussed above is the same as with the old implicits, so I’m assuming it’s intentional.

I’m talking in general, I don’t have time right now to look into details at all the code samples flying around above.

2 Likes

Yes, each thing you need to import is a cost for users who want to access the feature. IIRC, referring to this as a “tax” on the feature originated in a talk from the 2011 Northeast Scala Symposium and a followup article.

As I said above, I’m not opposed to this for enabling additional syntax, I am primarily annoyed at the presumption of incompetence I received when pointing out that you do actually need to define and import a separate ops object to contain the extensions in some cases.

Your alternative suggestion to define them in the companion object and bulk import the givens is unworkable for two reasons:

  1. It’s the same number of imports.
    If you go back and check my example, you’ll note that I did not need to import the instances, only the syntax, so your suggestion is equivalent.
  2. The blanket import flattens the given prioritization, removing the ability to define relative priority.

The implementation is fine, though if the boilerplate could be reduced it’d be awesome. Being told that a simplistic example in the docs means I can’t possibly be correct about more complicated behavior is not.

1 Like
  1. Yes, they are equivalent, but I don’t understand how the import is a boilerplate. You are going to have to import something; I presume you’re not expecting the compiler to search for the entire classpath aimlessly.

  2. I’m not sure what you mean by that.

Wouldn’t allowing naming types at the use site mostly fix the problem here? For example 1.pure[F = Option]. Sadly AFAIK that proposal was dropped from Scala 3.

Import._ is expensive operation.

  • If someone does not remember it by heart he will have to go to documentation
  • if someone does not have a reflex to type it, he will feel annoyance and make an errors or be puzzled why it is not working at least until he get used to it.

In most cases an ide do the import automatically. But ‘import *.given’ is not such case at all.
It is not problem for main libraries, it is not problem for rare libraries. But I have to write decorators for middle cases because there are no other way to improve developers’ productivity.
So I don’t like ‘import *.given’ personally for that. Most people in our company do not work with it and thay dont care about it :slight_smile:
But I am not sure that it is good characteristic when even Martin says that it is good to avoid it.
Of course I can imagine that there are people that have no problem with it. But we had it and we try to avoid it.
IMHO: Typeclasses is a killer feature of scala and it is sad that it has such drawback.

2 Likes

No one told you that you “can’t possibly be correct”. Please be more measured and less combative in your tone on this forum (this isn’t the first time I’ve noticed it).

3 Likes

You mean expensive compared to import X (without _)? If so, then this has more to do with import-syntax than with implicits, because this applies to non-implicits as well.

I actually think that this “boilerplate” might be necessary to make implicits easier to comprehend and work with (that’s the main goal here, remember?). Making a “normal” import also include implicit interpretations (type instances, conversions, extension methods) is a side effect, as import is first and foremost a name resolution tool.

I usually do not do import X, a code assistant help me with that task.
So these things are not comparable.

I do not think so for many cases. But It is a question out of my competence level in any case :wink:

1 Like

My point was that the number of imports in your suggestion was equivalent, and moving the definitions into the companion isn’t doesn’t refute my original point that just defining them as extension methods in the trait doesn’t always work.

On of the primary techniques for prioritizing implicits in Scala 2 is nested traits, which still exists and works in Dotty. Bulk importing that flattens them into a single priority level, which is trouble if you have otherwise ambiguous definitions.

For example, assuming you have some typeclass Foo which can be derived from Order or Ordering and you want to provide a lift from these to Foo, you can’t do that by defining them both in the companion object. One needs to have lower priority, which is done by defining it in a trait which the companion object extends. import Foo.given undoes this.

You did, actually. Repeatedly.

For example, when I sketched out here my previous experience with being unable to get the simpler version you suggested to compile, including a link to the actual code exhibiting this problem, your response was only shades better than RTFM:

Your follow up also presumed that I hadn’t read the docs, nor tried their example, which I’d indicated I had:

Finally, when I minimized and zeroed in on the exact issues, your response was unpleasantly dismissive:

And yes, I imagine you’ve noticed my annoyance with “Why don’t you just use macros?”, and the similar attitude of “We think this is easy, why can’t you figure out these undocumented APIs?” which has been pointed in my direction multiple times. It grates on the nerves after a while. While I strive for respectful conversation, I’m not going to apologize for getting annoyed when talked down to.

1 Like