The scheme looks at first glance quite reasonable to me. Definitely worth following up, maybe leading to a pre SIP? I like unrolledDefaults
as a name for the annotation.
There should be some examples with multiple parameter lists. An example where the default refers to a parameter from a earlier list. It seems it should work out.
For the case classes, you also need to override fromProduct
, right? It could look something like this
object Person:
@synthetic
def fromProduct(p: Product): Person = p.productArity match
case 2 =>
Person(
p.productElement(0).asInstanceOf[String],
p.productElement(1).asInstanceOf[String],
)
case 3 =>
Person(
p.productElement(0).asInstanceOf[String],
p.productElement(1).asInstanceOf[String],
p.productElement(2).asInstanceOf[String],
)
case 4 =>
Person(
p.productElement(0).asInstanceOf[String],
p.productElement(1).asInstanceOf[String],
p.productElement(2).asInstanceOf[String],
p.productElement(3).asInstanceOf[Option[String]],
)
Shout out to @armanbilge who discovered this
I don’t think so. AFAIK, fromProduct
is only used by Mirror
s (typeclass derivation). So, if you want to support typeclass derivation you have to implement a custom fromProduct
, otherwise you don’t need it.
So, if you want to support typeclass derivation you have to implement a custom
fromProduct
, otherwise you don’t need it.
Are you saying that by not implementing a custom fromProduct
, you’re essentially prohibiting typeclass derivation? Shouldn’t then the recommended practice be to implement it? What do you know about what the users of your case class want to use it for? The chance that they will want working derivation is high.
Yes. I was still seeing things along the lines of the linked Pre-SIP, which explicitly ignored the derivation use-case because it was impossible to implement correctly with that approach.
But I agree that if there is a solution that works with the approach based on parameters with default values (as described in your post), that’s good to have!
@lihaoyi do you plan to move this idea forward yourself? Otherwise, if it is not too urgent, it seems like this could be a nice and self-contained subject for a student project next semester at EPFL (September-February). What do you think?
I don’t have immediate plans to move this forward. Feel free to commandeer the idea and turn it into a real project!
Would it be possible to make this behavior the default?
That would be the best programmer experience, wouldn’t it? Not having to worry about binary compatibility anymore
Just wanted to bump this again, with another concrete use case I encountered:
My last update to the com.lihaoyi::mainargs
library involves adding a new default parameter to a bunch of user-facing methods. As a result, a ton of method signatures needed to be duplicated and “manually telescoped” or “manually unrolled” to maintain binary compatibility
Some of these signatures were already duplicated twice for compatibility concerns in the past, and now are duplicated three times.
While extemely tedious, it is impossible for a library to simultaneously (a) make use of Scala language features, like default argument values and (b) provide a smooth user experience free from NoSuchMethodError
s and the like and (c) avoid this duplication. That puts library authors between a rock and a hard place, having to give up one of them:
- Some libraries give up (a), limiting themselves to a subset of Scala that doesn’t use default arguments, and forcing additional builder-pattern boilerplate on all their users
- Some libraries give up on (b), expecting that users will hit JVM
LinkageError
s sometimes and be forced to recompile their un-changed source code against newer library versions - Some libraries give up on (c), and fill their implementation with boilerplate telescoping methods.
For Mainargs I’ve chosen to give up (c), and decided to live with the boilerplate in exchange for providing an optimal user experience. But these telescoping/unrolled binary compatibility shims are extremely mechanical, and it should be straightforward to automate their generation via a compiler plugin or annotation macro.
I don’t have any concrete implementation to show yet, just wanted to keep the conversation going as I encounter these cases in the wild
Scala stewards, please take note of this
This language change is one of the few that will improve Scala users lives the most, especially the library authors.
The efforts that need to be put up to ensure binary compatibility are painstaking. This would help a great deal.
/cc @Kordyjan
data-class lets you put an annotation (@since
) on the first “new” paramter, which reduces the number of synthetics if the initial version already uses default arguments.
There are probably some tricky aspects in here. If we have
trait T {
@telescopingDefaults def f(x: Int, y: Int = 1) = 0
@synthetic def f(x: Int) = f(x, 1)
}
We want t.f(42)
to compile to the non-synthetic overload. We also want to hide the synthetic one in IDEs and Scaladocs. But the method should probably still be there, for example for the Mixin phase to generate forwarders. But it looks all doable to me.
I tried a few examples around overriding and couldn’t find issues, it seems the scheme would work well. Existing subclasses would override the new synthetic method, newly compiled subclasses would not be source-compatible, so they have to be rewritten to override the new signature (and get an overriding synthetic method).
I wonder if this transformation could be done conpletely at the bytecode level. e.g. via ASM rather than via a compiler plugin. That would allow us to share the implementation between Scala2 and 3.
After all, generating bincompat forwarders seems purely a JVM-level concern, and the only thing Scala related is knowing how to call Scala default argument value methods inside the forwarders. Apart from that, the Scala compiler should not need to know about these forwarders at all and vice versa
It’s also a Scala.js IR and Native IR concern. So you’d have to do the work 3 times.
That’s true. I guess it might save effort doing it i the compiler then, though it would still need to be done twice for Scala 2 and 3
Similarly, we should allow passing optional values without Some() wrappers, allowing fields to be later declared as optional without breaking existing code and without annoying syntax overhead. Wdyt?
I dream that the following code works in Scala:
def foo(a: String: b: Option[String], c: Option[Int]): String = ???
foo("bar", "baz", 5)
foo("bar", "baz", Some(5))
foo("bar", Some("baz"), 5)
foo("bar", Some("baz"), Some(5))
foo("bar", None, 5)
foo("bar", "baz", None)
foo("bar", None, None)
There’s always this temptation to make options easier, for example like you said
And these often work perfectly, as long as the types are know and there’s only one level of option:
def foo(opt: Option[Option[Int]]) = ???
foo(None) // Is this Some(None) or None ?
def bar[T](opt: Option[T]) = opt
def baz[T](x: T) =
bar[T](x) // implicit wrapping
// my intuition:
bar[Option[Int]](None) // None // not Some(None)
baz[Option[Int]](None) // bar(Some(None)) => Some(None) // not None
But you’ll notice if you inline baz
, you get bar
!
For this reason, I believe we can’t, or at least shouldn’t add utilities like the one proposed
(But I was victim of similar ideas many times, so I understand the appeal !)
I started taking a crack at Scala 2 and 3 compiler plugin implementations of a @Unroll
annotation:
- Scala 2 Implementation unroll/unroll/src-2/UnrollPhaseScala2.scala at main · lihaoyi/unroll · GitHub
- Scala 3 Implementation unroll/unroll/src-3/UnrollPhaseScala3.scala at main · lihaoyi/unroll · GitHub
The goal being to take something like this
@unroll.Unroll("n")
def foo(s: String, n: Int = 1) = println(s * n)
And unroll it into something like this
def foo(s: String, n: Int = 1) = println(s * n)
def foo(s: String) = foo(s, n = 1)
I have some basic tests passing in Scala 2, but I’m stuck on the following crash in Scala 3
[error] value foo$default$3 is not a member of unroll.Unrolled - did you mean (Unrolled.this : unroll.Unrolled).foo$default$3?
[error] value foo$default$4 is not a member of unroll.Unrolled - did you mean (Unrolled.this : unroll.Unrolled).foo$default$4?
[error] value foo$default$4 is not a member of unroll.Unrolled - did you mean (Unrolled.this : unroll.Unrolled).foo$default$4?
Anyone here have Scala 3 compiler expertise to advise what I may be doing wrong? The synthetic method i want to generate should be pretty straightforward, but I’m having trouble figuring out exactly what the Scala 3 compiler expects of me here. The repository has a readme with the relevant commands to run, if you want to try it out yourself GitHub - lihaoyi/unroll
this was resolved on Discord - but for those curious, Names in Scala 3 compiler are not just interned strings, but a tree structure, so default names have a specific constructor or else they won’t be found as a member
Exactly. The concept is called semantic names. It prevents you from accidental name collisions and misinterpretations.