Use Cases for Implicit Conversion in Bulk Extensions

I’ll continue the theme here with some feedback about deprecation of Implicit Conversions in my pet flavor.

In the pre-sip thread, it is suggested that implicit conversions for bulk extension can be replaced with a construct like this:

Unfortunately, I believe that will break a lot of code. Extensions as they currently exist break right-associativity*. So, every line of code that uses +: or ++: (just to mention the ones on ArrayOps) will be broken and need to have its operands flipped to compile again**.

(* For some definitions of “broken” and “by design”)
(** This one is true regardless of what you think about the intentionality of extensions behavior with right-associativity)

Of course, you could perhaps come up with a means to exclude the right-associative operators from the export above and then add extensions to the Array object like so:

extension [A] (a: A)
  def +: (arr: Array[A]) = ...

extension [A] (it: IterableOnce[A])
  def ++:(arr: Array[A]) = ...

extension [A] (arr: Array[A])
  def ++:(arr: Array[A]) = ...

But then we’re right back to this issue:

Not to mention that you will then have to do the same for every type that currently benefits from implicit conversion to every other collection type in the standard library. And that’s not to mention code outside of the standard library making use of implicit conversion to types with right-associative operators on them.

It seems to me that we either need a very long lead time to wean the entire ecosystem off of right-associativity in every use case, or we will still need some mechanism for adding them to (er… extending(?)) types as they currently behave.

Having said all of this, I’m not particularly a fan of implicit classes / conversions. But given the behavior of extensions around this topic, I don’t currently see another option that can achieve the same goals in this regard.

1 Like

I’m not so sure I follow your claim

e.g. in Scala 3.3.1 here I define an alternative syntax for tuple prepend/append and it behaves the same at the call-site if I enable the extension method via implicit conversion, or bulk export:

class ConcatOps[T <: Tuple](ts: T):
  def %: [U](elem: U): U *: T = elem *: ts
  def :% [U](elem: U): Tuple.Append[T, U] = ts :* elem

object ImplicitConversions:
  given [T <: Tuple]: Conversion[T, ConcatOps[T]] = ConcatOps(_)

  def test: (Int, String, Boolean) =
    1 %: "abc" %: EmptyTuple :% true

object Bulk:
  extension [T <: Tuple](ts: T)
    def concatOps: ConcatOps[T] = ConcatOps(ts)
    export concatOps.*

  def test: (Int, String, Boolean) =
    1 %: "abc" %: EmptyTuple :% true
1 Like

Ok, so shockingly you are absolutely correct (not shocking that you are correct… shocking that this works). However, I do think this whole thing highlights the absurdity of extension handling of right-associative methods:

scala> extension [T <: Tuple](ts: T)
   def concatOps: ConcatOps[T] = new ConcatOps(ts)
   export concatOps.*

def concatOps[T <: Tuple](ts: T): ConcatOps[T]
def :%[T <: Tuple](ts: T)[U](elem: U): Tuple.Append[T, U]
def %:[T <: Tuple](ts: T)[U](elem: U): U *: T

scala> 1 %: "abc" %: EmptyTuple :% true
val res0: (Int, String, Boolean) = (1,abc,true)

scala> extension [T <: Tuple](ts: T)
   def &: [U] (elem: U): U *: T = elem *: ts

def &:[T <: Tuple][U](elem: U)(ts: T): U *: T

scala> 1 &: "abc" &: EmptyTuple :% true
-- [E007] Type Mismatch Error: -------------------------------------------------
1 |1 &: "abc" &: EmptyTuple :% true
  |     ^^^^^
  |     Found:    ("abc" : String)
  |     Required: Tuple
  |
  | longer explanation available when compiling with `-explain`
1 error found

Notice this:

def %:[T <: Tuple](ts: T)[U](elem: U): U *: T //From export
//vs
def &:[T <: Tuple][U](elem: U)(ts: T): U *: T //From direct declaration

I think I’ve lost the thread again on how it is, exactly, that extensions are not broken with respect to right-associative operators.

So, you are absolutely correct that the specific syntax I highlighted and you demonstrated does work. But I’m not convinced that it isn’t a bug given the stated intent and behavior of the operator when directly declared in an extension.

The inconsistency and confusion around extension behavior seems relevant enough on its own as an objection to completely ridding ourselves of the tried-and-true method of bulk extensions.

Thank you for pointing out my mistake. I happily refine and clarify my objection as stated.

1 Like

So the compiler automatically flips the arguments for right associative extension methods, and in fact it prints the correct way to use the method, e.g. it prints

def &:[T <: Tuple][U](elem: U)(ts: T): U *: T

which expects an element on the left, and a tuple on the right, which matches the type error.

Unfortunately the repl seems to not print the flipped arguments of the right associative methods when defined through the bulk export

I don’t think the problem is with the REPL’s print out of the extension method.

The compiler flips operands at the call site during compilation of right-associative methods. Since it was somehow decided that this is “bad” the implementation of extension proactively swaps them first at the definition site so that when they are flipped at the call site, it comes out “correct”.

As a result, the correct way to satisfy

def &:[T <: Tuple][U](elem: U)(ts: T): U *: T

is with a call that looks like this:

ts &: elem

which gets turned into something similar to elem.&:(ts) by the compiler. Or, I guess, in this case:

&:(elem)(ts)

So the tuple on the left and the element on the right at the call site (not element left and tuple right as you said… that’s done by the compiler and matches the REPL printed structure above).

Which is backwards. Just to simplify and clarify, here are two invocations of the two methods (exported vs direct):

scala> "a" %: EmptyTuple
val res0: String *: EmptyTuple.type = (a,)

scala> EmptyTuple &: "a"
val res1: String *: EmptyTuple.type = (a,)

Notice how “the COLon goes on the COLlection side” no longer applies to methods declared as extensions?

I can’t speak to how the compiler handles the export of class methods or why the export version doesn’t introduce the broken behavior (as far as I’m concerned) but the direct declaration does. But either way this is further evidence of inconsistency in extension behavior around this topic and that is concerning.

On the plus(?) side, I suppose I’ve learned that I can get around the broken behavior by declaring my right-associative methods in a class and exporting them through the extension I want instead of attempting to declare them directly as extensions. I’m not sure this is a win, but I know more than I did, so that’s something I guess.

2 Likes

Have you read this page ?
https://docs.scala-lang.org/scala3/reference/contextual/right-associative-extension-methods.html#

We tried to make it as clear as possible, given these are somewhat confusing, so if you had read it before, your feedback would be much appreciated