Can We Wean Scala Off Implicit Conversions?

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

In this case, we can avoid the extension noise by using SAM syntax (I’ve just tried, it works):

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

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

I agree. But the two standard library conversions are there essentially for the extension methods they provide. I.e. I want all sequence ops on Arrays. But I rarely want to silently convert an array to a sequence. I think for that case demanding an explicit toSeq is fine. So I’d try to model the Array and String conversions as extension methods instead.

True. Complexity is created by “unkown unknowns”. That means as long as its your own code, and you know what you are doing, you might prefer implicit conversions. But as soon as it leaks out to users of libraries, I’d try to avoid them. I believe the language import is the right control mechanism then: Use it in your own code, but don’t force it on your users.

[EDIT] Here is an idea to improve usability of conversions:

  1. Implement an extension method convert in Conversion:

    trait Conversion[-A, +B]:
      def apply(x: A): B
      extension (x: A) def convert: B = apply(x)
    
    
  2. Offer a refactoring in the IDE or compiler to rewrite every implicitly inserted conversion on E to E.convert.

That way a workflow could look like this:

  • Compile with language.implicitConversions
  • Before you ship, apply the refactoring, and scrutinize your code for unwanted conversions and possible further refactorings.
2 Likes

That sounds like a great idea. And once it’s converted to .convert, regular refactoring tools in IDEs are more likely to be able to change it to something else if you prefer something else.

I’m not really sure how replacing Magnet with conversion can cover magnets that are using dependent types:

trait Magnet {
  type Out 
  def value: Out
}

def complete(magnet: Magnet): magnet.Out = magnet.value

Akka http DSL is built on this pattern. And loosing such api might be a big drawback/limitation for the community to switch on dotty.

1 Like

I’m not really sure how replacing Magnet with conversion can cover magnets that are using dependent types:

trait Magnet {
  type Out 
  def value: Out
}

def complete(magnet: Magnet): magnet.Out = magnet.value

Akka http DSL is built on this pattern. And loosing such api might be a big drawback/limitation for the community to switch on dotty.

I guess the same behaviour could be achieved by using type classes: Scastie - An interactive playground for Scala.

2 Likes

I think option2Iterable in most cases goes to the Implicit conversions are Evil category.
For example I hate that

def conv(s: String): Option[String] = ???
List.empty[String].flatMap(conv)

compiles. convs return type has nothing to do with lists so why we can use .flatMap?. It was a big struggle for me at foreign codebases, and it was a big struggle for every beginner I pair-programmed. I usually write an extender, or use .map().collect(), or .map().flatten. The extender and the flatten always raise a red-flag in the reader (why it is not a .flatMap? ohh because it does not return a list), and the collect is explicit.

Also, I think the ++ syntax can lead to confusion without the .toList. (I understand why the Option is a special list, but still, you need to keep a smaller codebase in your head when you read others code if these are explicit.)

The only place where I like implicit conversions, where I need to change/refactor data structures for prototypes, and not in the whole app. (For example, changing two database views to one external source, and I don’t need to modify the code everywhere if I write the deriving conversions from the new format to the old format in the testing/prototyping period.) But I would let this convenience go if we get rid of the option2Iterable and we can still write extender methods,

4 Likes

My 2c are that I have at least a dozen libraries that use pattern similar to String => Frag. For example:

sealed trait GE  // graph element
case class Constant(value: Double) extends GE
trait UGen extends GE // unit generator dsp block
    
implicit def doubleIsConstant(value: Double): Constant = Constant(value)

or

trait Ex[+A] {  // expression
  def value: A
}

implicit def doubleConstant(value: Double): Ex[Double] = ...
implicit def stringConstant(value: String): Ex[String] = ...

This doesn’t work with just extension methods, and type class approach would just be horrific (I would hate Scala if it forced me to pass type classes here everywhere). This is a very fine case for implicit conversions (or implicit constructor as lihaoyi calls it) IMHO.

case class SinOsc(freq: GE, phase: GE) extends UGen

SinOsc(400.0, 0.0)  // ok
case class PrintLn(in: Ex[String], tr: Trig) extends Act

PrintLn("Hello world", LoadBang())  // ok
1 Like

Looking at what has been discussed so far, I have to say that I find implicit constructors significantly easier to understand than context-bound variadic generic tuples. Implicit constructors aren’t unique to Scala, and C# and C++ both have them. People coming from Java know of the adapter pattern, and implicit constructors just smooth it out a bit. Far fewer people would know how to work with context-bound variadic generic tuples.

If you look at the problems people have with implicit conversions, it’s almost always the fact that they provide extension methods when they really just wanted an implicit constructor, or vice versa. I think narrowing “implicit conversions” to “implicit constructors” and forcing people to jump through additional hoops in the occasional case that they want both would be a reasonable way forward. It would fix ~all the existing issues with conversions and provide hardcoded support for the two most common use cases (extension methods and implicit constructors) in a simple and understandable fashion.

The proposed def f[T: Fragable](x: String, y: T**) = ... does look workable, and isn’t too awkward. Maybe if we paper over the context-bound variadic generic tuple stuff with nice enough syntax it’ll be just another magic incantation that people can cargo cult? The fact that it desugars to something else could be an implementation detail.

Having a python-like ** syntax that works for any ProductN could have a lot of value beyond just dealing with Fragable, but that may be a different discussion

4 Likes

Unfortunately you suggestion doesn’t cover return type. I should have specified this explicitly, what I’m looking is achieving following akka http DSL:

parameters("arg1".as[String], "arg2".as[Int]) { (arg1, arg2) =>
  // ...
}
// or
parameters("arg1".as[String]) { (arg1) =>
  // ...
}

this two code sample utilizing 3 features - auto tupling, magnet pattern and dependent types:

def parameters(pdm: ParamMagnet): pdm.Out = pdm()

where Out might be Function1, Function2 etc, depends on resolution of Magnet patten. It might be possible to achieve in position of type parameter but it might be lacking of type inference because lack of type context. I will try to play with it tomorrow.

3 Likes

Dotty has auto untupling for functions or whatever it’s called, which might make this a lot easier. But it’s also possible with a typeclass: https://scastie.scala-lang.org/gjrW5eOQQqaes1xP7HVunQ

3 Likes

I was unfamiliar with the term, so looked up the C# reference. In fact these look to me just like implicit conversions, with the restriction that they have to be defined in the implicit scope (as we would call it) of the source or target type. C#'s implicit scope definition is very close to Scala’s. I don’t know whether one can define a proper subclass of implicit conversions that are just implicit constructors. How would you define them?

The fact that both C# and C++ have user-defined implicit conversions (and very few other languages have them, it seems) does not count as a recommendation for me. These are literally the two most complex mainstream languages out there.

3 Likes