Default arguments in Scala have a number of annoying restrictions which arise from the way they are implemented. Currently default arguments are implemented by virtual default getter methods. Since every default getter can be overridden in a subclass, there has to be a strict scheme regulating the names and type signatures of default getters that works with separate compilation between superclass and subclass. This results among others in the following shortcomings and restrictions:
Default getters of curried functions get very large parameter signatures - they have to include all parameters in previous sections, just in case an overriding method wants to use one of these in a default argument.
Default arguments cannot refer to previous parameters in the same parameter sections -
implementing this would lead to an explosion in the complexity of getter definitions.
In a set of overloaded alternatives, only one alternative can have default parameters -otherwise naming default getters would get too complicated.
Default parameters cannot be combined in expressive ways with givens - there no way to compute a default argument by an implicit search at the call site.
We can drop all these restrictions by treating default arguments as inline expressions. Instead of invoking a virtual default getter, we synthesize the default argument directly at the call site.
The only downside is that then an overriding method cannot change a default argument with a different expression, since inlining is resolved statically, but the current semantics is dynamic virtual dispatch. I am trying to find out how much of a problem this would be. In the code I have looked at, I have seen a few occurrences where the overriding method repeats a default argument of the overridden method. This is redundant, since default arguments are inherited. I have seen no case so far where an overriding method changed the default argument of the original method. So maybe such cases are rare enough to be outlawed?
It would be nice if there was a mechanism that I could opt-into so I could add a new param to ModuleID.apply in a backward compatible way (i.e. without recompilation of the clients). In the above, I am envisioning @dynamic to pass in a dictionary of key-value at call-site at v1, and v1.1 being able to handle the missing field somehow.
IMHO this point alone already makes it worth considering. Being able to provide overloaded alternatives with default parameters is a big win for certain kinds of APIs. I’ve seen people doing all kinds of hacks with implicit conversions or union types to work around that limitation.
I.e. from a project readme just seen yesterday:
One major annoyance with Scala in particular is that you cannot define multiple overloads of a method that take default arguments … In some cases, clever use of Scala’s implicit conversions can hide these headaches, but currently, you occasionaly have to write out the defaults where you would not have to in Python.
The most common case of the default argument is probably None as an initial value of an optional parameter. Could it be maybe set to default None by the compiler implicitly? That would reduce some boilerplate since the very usage of Option means we expect it to be missing. WDYT?
Looking at my codebase, it is usually either:
some obvious static value for a public API method parameter, never overridden in practice,
a specific contextual value, which also never gets overridden since these methods tend to be exclusively private or embedded
a large pool of default values in the code supporting testing - usually there are more variations here, but no clear case for overriding default arguments either
Barring some technical difficulties with Scala.js, I support the spirit of this change. That said, it’s going to be very tricky to find an implementation that stays TASTy and binary compatible, so this is probably only going to be truly possible in the next breaking version.
In terms of implementation strategy, I am wary of relying on inlining. Inlining is a treacherous tool that has severe implications in terms of separate compilation. I don’t think we should rely on it for something like default parameters, which look very much like they should be handled at definition site. That intuition is reinforced by the fact that other mainstream languages with default arguments do handle them at definition site.
I would favor an implementation scheme similar to that of Kotlin: a unique companion method taking a bitset of the arguments that are explicitly passed, plus all the arguments. Non-passed arguments are set to the zero of their type at call site. The companion method inspects the bit set to fill in the missing arguments before delegating to the real method.
While on the topic, another footgun I’d like to see removed about default arguments is that their right-hand-side is not typechecked as the declared type of the parameter. This is also a source of innocuous binary incompatibilities.
Changing this would break a lot of code, including all definitions of copy methods in generic case classes. Consider:
case class C[T](x: T)
This generates a copy method
def copy[U](x: U = this.x): C[U]
Note that this.x has type T, not U.
And I am afraid it looks like this also makes the Kotlin implementation scheme unworkable. The way I understand it, with that scheme, case class copy would be translated like this:
case class C[T](x: T):
def copy[U](x: U) = new C[U](x)
def copy$default[U](x: U, present: Int): C[U] =
copy(if (present & 1) == 0 then x else this.x)
That won’t typecheck:
-- [E007] Type Mismatch Error: test1.scala:4:48 --------------------------------
4 | copy(if (present & 1) == 0 then x else this.x)
| ^^^^^^
| Found: (C.this.x : T)
| Required: U
I linked this on Mastodon, and it got fairly heavily reposted around the Scala community there. So far, I haven’t heard anyone in favor of overrides being able to change the default, and a few who echoed my reaction of, “That’s possible? No, no, no – I don’t ever want to see that in my codebase.”
So (granting the challenges of the timing and implementation details), strong agreement with the principle of the change.
Or in other words, no copies, lenses, optics that change the type? Because none of that will work anymore. That’s analogous to only have map(f: T => T) but never map(T => U).
After further discussion, a compromise might be possible:
In the cases where the right-hand side can never type check, throw an error
For example:
class C[T](x: T):
def copy[U](x: U = this.x): C[U] // can typecheck (if U :< T), so no error
// at least:
def bad1(s: String = 4): Int // can never typecheck, so error
// maybe even:
def bad2[S <: String](s: S = 4): Int // can never typecheck, so error
This might be difficult to achieve technically, because of the way they are treated internally now
Ah, that was a misunderstanding then! I also think that changing default arguments in overridden methods is probably not widely used. @jducoeur sorry for not reading your message more closely. Sometimes these threads meander between different topics in unpredictable ways.