Motivation and Background
We had already two long threads about implicit conversions in Scala 3:
- Can We Wean Scala Off Implicit Conversions?
- Proposed Changes and Restrictions For Implicit Conversions.
At the time we identified a lot of issues with implicit conversions and discussed possible remedies, but we did not arrive at a solution that felt 100% convincing.
Here is the problem: Scala 2 implicits are going away. Not right now, but we should start planning to phase them out. The old style implicit def
conversions will need to be replaced by instances of the Conversion
class. Unlike old implicit def, Conversion
requires a language import
import scala.language.implicitConversions
for the files in which implicit conversions are used (i.e. expected to be inserted implicitly, explicit conversions are fine).
If the import is missing, a feature warning is currently issued, and this will become an error in future versions of Scala 3. The motivation for this restriction is two-fold:
- Code with hidden implicit conversions is hard to understand and might have correctness or performance issues that go undetected.
- If we require explicit user-opt in for implicit conversions, we can significantly improve type inference by propagating expected type information more widely in those parts of the program where there is no opt-in.
So, how much of a restriction will that be? We identified one broad use case where implicit conversions are sometimes very hard to replace. This is the case where an implicit conversion is used to adapt a method argument to its formal parameter type. An example from the standard library:
scala> val xs = List(0, 1)
scala> val ys = Array(2, 3)
scala> xs ++ ys
val res0: List[Int] = List(0, 1, 2, 3)
The input line xs ++ ys
makes use of an implicit conversion from Array[Int]
to IterableOnce[Int]
. This conversion is defined in the standard library as an implicit def
. Once the standard library is rewritten with Scala 3 conversions, this will require a language import at the use site, which is clearly unacceptable. It is theoretically possible to avoid the need for implicit conversions using method overloading or type classes, but
- this would often lead to longer and more complicated code,
- it would break binary backwards compatibility, and
- it would not work for vararg parameters.
Previous proposals concentrated on some kind of modifier (in its latest incarnation it was called into
) that would allow conversions on function arguments. There were some concerns that this would be too inflexible and that it would be better to make into
be a type that can be used anywhere, including in type parameters and type aliases. That is what this pre-SIP proposes.
Proposal
I propose a new type constructor into[T]
that is treated specially in the compiler to enable fully implicit conversions that do not require a language import. As a first example, here is a signature of a ++
method on List[A]
that uses it:
def ++ (elems: into[IterableOnce[A]]): List[A]
The into
wrapper on the type of elems
means that implicit conversions can be applied to convert the actual argument to an IterableOnce
value, and this without needing a language import.
into
is defined as follows in the companion object of the scala.Conversion
class:
opaque type into[T] >: T = T
Types of the form into[T]
are treated specially during type checking. If the expected type of an expression is into[T]
then an implicit conversion to that type can be inserted without the need for a language import.
Note: Unlike other types, into
starts with a lower-case letter. This emphasizes the fact that into
is treated specially by the compiler, by making into
look more like a keyword than a regular type.
Example:
given Conversion[Array[Int], IterableOnce[Int]] = wrapIntArray
val xs: List[Int] = List(1)
val ys: Array[Int] = Array(2, 3)
xs ++ ys
This inserts the given conversion on the ys
argument in xs ++ ys
. It typechecks without a feature warning since the formal parameter of ++
is of type into[IterableOnce]
, which is also the expected type of ys
.
into
in Function Results
into
allows conversions everywhere it appears as expected type, including in the results of function arguments. For instance, consider the new proposed signature of the flatMap
method on List[A]
:
def flatMap[B](f: A => into[IterableOnce[B]]): List[B]
This accepts all actual arguments f
that, when applied to a value of type A
, give a result that is convertible to type IterableOnce[B]
. So the following would work:
scala> val xs = List(1, 2, 3)
scala> xs.flatMap(x => x.toString * x)
val res2: List[Char] = List(1, 2, 2, 3, 3, 3)
Here, the conversion from String
to Iterable[Char]
is applied on the results of flatMap
ās function argument when it is applied to the elements of xs
.
Vararg arguments
When applied to a vararg parameter, into
allows a conversion on each argument value individually. For example, consider a method concatAll
that concatenates a variable number of IterableOnce[Char]
arguments, and also allows implicit conversions into IterableOnce[Char]
:
def concatAll(xss: into[IterableOnce[Char]]*): List[Char] =
xss.foldLeft(List[Char]())(_ ++ _)
Here, the call
concatAll(List('a'), "bc", Array('d', 'e'))
would apply two different implicit conversions: the conversion from String
to Iterable[Char]
gets applied to the second argument and the conversion from Array[Char]
to Iterable[Char]
gets applied to the third argument.
Unwrapping into
Since into[T]
is an opaque type, its run-time representation is just T
. At compile time, the type into[T]
is a known supertype of the type T
. So if t: T
, then
val x: into[T] = t
typechecks but
val y: T = x // error
is ill-typed. We can recover the underlying type T
using the underlying
extension method which is also defined in object Conversion
:
import Conversion.underlying
val y: T = x.underlying // ok
However, the next section shows that unwrapping with .underlying
is not needed for parameters, which is the most common use case. So explicit unwrapping should be quite rare.
Dropping into
for Parameters in Method Bodies
The typical use cases for into
wrappers are for parameters. Here, they specify that the corresponding arguments can be converted to the formal parameter types. On the other hand, inside a method, a parameter type can be assumed to be of the underlying type since the conversion already took place when the enclosing method was called. This is reflected in the type system which erases into
wrappers in the local types of parameters as they are seen in a method body. Here is an example:
def ++ (elems: into[IterableOnce[A]]): List[A] =
val buf = ListBuffer[A]()
for elem <- elems.iterator do // no `.underlying` needed here
buf += elems
buf.toList
Inside the ++
method, the elems
parameter is of type IterableOnce[A]
, not into[IterableOne[A]]
. Hence, we can simply write elems.iterator
to get at the iterator
method of the IterableOnce
class.
Specifically (meaning in spec-language): we erase all into
wrappers in the local types of parameter types, on the top-level of these types as well as in all top-level covariant subparts. Here, a part S
of a type T
is top-level covariant if it is not enclosed in some type that appears in contra-variant or invariant position in T
.
Into in Aliases
Since into
is a regular type constructor, it can be used anywhere, including in type aliases and type parameters. This gives a lot of flexibility to enable implicit conversions for user-visible types. For instance, the Laminar framework
defines a type Modifier
that is commonly used as a parameter type of user-defined methods and that should support implicit conversions into it. Patterns like this can be supported by defining a type alias such as
type Modifier = into[ModifierClass]
The into-erasure for function parameters also works in aliased types. So a function defining parameters of Modifier
type can use them internally as if they were from the underlying ModifierClass
.
Implementation
A full implementation of the proposal is provided by this PR:
Alternatives
- Make
into
a modifier, as in the experimental scheme supported until now. The proposal here is a lot simpler since it makes use of the power of the type system instead of building up a parallel structure based on modifiers. It is also considerably more flexible than the previous scheme. One open question might be whether itās too flexible. - Also allow implicit conversions on literals without requiring an
into
in the target type. This would be even more flexible. I am not sure we need it, but we could add it later. - Go back to allowing all implicit conversions without restrictions or language imports, or with just some exclusions (like: no implicit conversions where extension methods would also do the trick). I believe allowing all conversions would perpetuate the problems we had with them in Scala 2, and am not convinced that we can find a class of āharmlessā conversions that are always unproblematic yet flexible enough for all use cases. Requiring explicit co-operation from libraries via
into
gives us more power to arrive at precisely tailored solutions.