Can We Wean Scala Off Implicit Conversions?

If we have extension methods, then we do not need implicit conversions.

But sometimes I wonder if extension methods are even more evil, pretending to be something they are not.

Correct me if I am wrong. But the pattern you describe where you use implicit conversions to go from T => Frags could also be possible with a type class right? You just have to call to toFrags function in that type class instead of directly expecting a Frags. What’s the benefit of using conversions? What do they offer that Scala can’t do yet in any other way?

2 Likes

I would imagine that a codebase like ScalaFX would have some problems if implicit conversions were removed. One of its selling points is “Seamless JavaFX/ScalaFX Interoperability”, and as far as I understand that seamless interoperability is provided by implicit conversions.

It essentially wraps Java objects in Scala objects, with implicit conversions between them, e.g.:

object Node {
  implicit def sfxNode2jfx(v: Node): jfxs.Node = if (v != null) v.delegate else null
}

One could of course fall back to using explicit methods e.g .toJfx, and .toSfx (as extension method, or alternatively SFX.fromJfx), but implicit conversions remove this “noise”.

In general I think allowing methods inside a class’ companion object for implicitly converting to/from that class is very useful, whether it is done with the current implicit def mechanism or something else.

1 Like

It cannot, as most functions take a variadic list of Frags which can each be of a different original type. You cannot replace that with a typeclass, as typeclasses can only support either a fixed number of arguments of different type or a variadic list of arguments all of the same type

9 Likes

There was a proposal floating around a while back that would allow variadic arguments of varying types, and have a typeclass instance associated with each one.

It might be worth revisiting as a way to address this usecase without implicit conversions.

3 Likes

You should be able to do without the type parameter by using a dependent function:

def complete(arg: Any)(using c: Conversion[arg.type, CompletionArg]) = c(arg) match {
    case Error(s) => ...
    case Response(f) => ...
    case Status(code) => ...
  }

Another limitation related to the vararg limitation is for string interpolators. Even when using a type class to define what can be unquoted in a custom interpolator (as in blah"... $a ... $b ..."), we need implicit conversions to actually apply the type class.
That’s actually because the string interpolation expands into a vararg call.

Love this idea. Weaning Scala off implicit conversions makes the language no less powerful or capable, and removes a source of confusion among new and more junior Scala developers. In addition, it makes it easier to read and understand the code at a glance, especially in cases like PR reviews where additional context is not available.

Big :+1: .

2 Likes

How about modelling a variadic list with a generic tuple? You can have a typeclass over that. Something like:

def f[T: Fragable](x: T) = ...

with a type class instance

given fragableTuple[X: Fragable, Xs: Fragable] as Fragable[X *: Xs]
2 Likes

Aside from being kind of ugly?

f((foo, bar, baz))

I’m fairly sure this doesn’t play nicely with infix methods, and there’s not really any support for string interpolators.

1 Like

It works out of the box for infix methods; they take a single argument, which can be a tuple. Only one pair of parentheses needed. For other methods, we’d have to rely on auto-tupling to avoid the double parentheses.

3 Likes

Any plans to make string interpolators tuple-friendly? I think that’s the last major usability pain-point

One issue with dependent function types is that I’ve constantly found them to mess up type inference: they often end up inferring singleton types when I don’t want them, resulting in too-narrow inferred types:

@ val x = os.root / "hello" // should return an `os.Path`
x: os.package.root.ThisType = /hello

I’ve hit similar issues trying to make Scalatags’ Frag type dependent. As a result, I studiously avoid using any sort of path-dependent types in any of my code, though if these edge cases are fixed then that opens up the possibility.

Honestly I have no idea. This whole “Generic Tuple” thing is new to me, and everyone else, so I don’t know enough to say for sure. For example, I don’t understand what that given fragableTuple instance means at all at a glance (Why are there two types X and Xs? Why do we need to implicits? What’s *:?)

3 Likes

I find implicit conversions incredibly useful for clean reuse of existing libraries. I almost never use implicit conversions when designing libraries where I have control of all the data types, but when consuming some other code that I don’t have control over, being able to seamlessly create a wrapper and pass it in wherever I need to, instead of having to call a method every time, results in incredibly clean code compared to every alternative I’ve seen (in Scala or other languages).

It’s definitely a feature that needs to be used carefully. But in contrast to the bewildering flurry of .into() in Rust code where there are lots of wrappings, the “non-obviousness” in Scala can be a godsend.

4 Likes

It’s all basically an officially-blessed version of HList…

Similarly, I found implicit conversions pretty useful in the process refactoring. When lots of code needs to be changed in repetitive ways, it’s often simpler to define an implicit conversion that does the job automatically. This way, it’s possible to see whether the refactoring works out or not, and once I’m sure that’s the way I want to go, I can remove the conversion and adapt all the call sites explicitly.

The danger is that one forgets the implicit conversion and leaves it in there (I’ve been guilty of that). Making the conversion deprecated from the start could help.

On the other hand, a proper refactoring tool like Scalafix could be more appropriate for that use case.

All in all, although they can be very convenient, I’d really like to see implicit conversions go, especially if it helps with the precision and performance of type inference. They’re too magical, and contribute to the outsider impression that Scala code is impossible to understand.

3 Likes

Union types are awkward to unworkable for the two standard library conversions. You would have to remember to always put String | Array[Char] | Seq[Char] for dealing with such collections, which is tedious at best, and is asking quite a lot for people to remember all the time. Additionally, if you have a generic type A, you can’t union String with your Seq[A] because you don’t know if it’s the correct type.

If we assume that it’s not just the standard library that has reason to use implicit conversions in that way, there isn’t a great replacement.

1 Like

Ok let me explain some more: *: is tuple cons. So *: is like :: and EmptyTuple is like Nil. For instance, the triple type (Int, String, Boolean) is an abbreviation for Int *: String *: Boolean *: EmptyTuple. Here’s the complete example for what I had in mind:

trait Frag

case class IntFrag(x: Int) extends Frag
case class StringFrag(x: String) extends Frag

trait Fragable[T]:
  def toFrags(x: T): List[Frag]

given Fragable[Int]:
  def toFrags(x: Int) = List(IntFrag(x))
given Fragable[String]:
  def toFrags(x: String) = List(StringFrag(x))
given [A](using af: Fragable[A]) as Fragable[List[A]]:
  def toFrags(xs: List[A]) = xs.flatMap(af.toFrags)
given Fragable[EmptyTuple]:
  def toFrags(x: EmptyTuple) = Nil
given [A, B <: Tuple](using af: Fragable[A], bf: Fragable[B]) as Fragable[A *: B]:
  def toFrags(x: A *: B) = af.toFrags(x.head) ++ bf.toFrags(x.tail)

def f[T](x: T)(using tf: Fragable[T]) =
  println(s"got: ${tf.toFrags(x).mkString(", ")}")

@main def Test =
  f(1)
  f("abc")
  f(List(1, 2, 3))
  f(1, "a", List("c", "d"))
4 Likes

I reformulated the example using the new syntax for extension methods, and it comes out even nicer:

trait Frag

case class IntFrag(x: Int) extends Frag
case class StringFrag(x: String) extends Frag

trait Fragable[T]:
  extension (x: T)
    def toFrags: List[Frag]

given Fragable[Int]:
  extension (x: Int)
    def toFrags = List(IntFrag(x))
given Fragable[String]:
  extension (x: String)
    def toFrags = List(StringFrag(x))
given [A: Fragable] as Fragable[List[A]]:
  extension (xs: List[A])
    def toFrags = xs.flatMap(_.toFrags)
given Fragable[EmptyTuple]:
  extension (x: EmptyTuple)
    def toFrags = Nil
given [A: Fragable, B <: Tuple: Fragable] as Fragable[A *: B]:
  extension (x: A *: B)
    def toFrags = x.head.toFrags ++ x.tail.toFrags

def f[T: Fragable](x: T) =
  println(s"got: ${x.toFrags.mkString(", ")}")

@main def Test =
  f(1)
  f("abc")
  f(List(1, 2, 3))
  f(1, "a", List("c", "d"))

Writing toFrags as an extension method takes a bit more space, but on the other hand, we do not need to name any Fragable instances, since extension method search summons the right instances already. Therefore, we can replace all using clauses with context bounds.

2 Likes

Is the plan still to completely remove auto-tupling eventually? Cause this seems to bring us back to what I said on the topic 2 years ago.

I would just like to point out that once you have HList-like tuples in Scala 3, having auto-tupling only for arguments which have a Tuple upper bound is a pretty great way to have syntax-free arity abstraction. It seems a bit strange to on the one hand build arity abstraction into the language and std library, and on the other remove a feature that could make arity abstraction great, which was available when arity abstraction was hard to do anyway.

Seems like in March it was still the plan to drop it completely.

Martin: In the future, there is no autotupling […]

7 Likes

Yes, maybe we should not go ahead with removing auto-tupling, after all. That was a good point you made. 2 years ago, we did not yet know for sure what we would have for generic tuples, but now it looks solid.

EDIT: It seems we’d need both more and less than autotupling. More: We should be able to pass the elements of a tuple after other arguments. Less: we need it only when the method specifies it. Which makes me think: Should we introduce a way to allow multiple elements of a tuple given as a varargs? I don’t know what the best syntax would be for this, for the sake of demonstration I am going to use ** instead of *. So it would be:

def f[T: Fragable](x: String, y: T**) = ...
2 Likes