Specifying one `using` parameter changes resolution of unrelated parameter!?

I’m seeing a confusing behavior where explicitly specifying one using parameter changes how other completely unrelated parameters are resolved:

case class A(s: String) 
object A:
  given A = new A("a companion")

case class B(s: String) 
object B:
  given B = new B("b companion")

def aMethod(using a: A, b: B = B("b default")) = println(s"a=$a b=$b")

aMethod
aMethod(using a = new A("a explicit"))
aMethod(using b = new B("b explicit"))

prints:

a=A(a companion) b=B(b companion)
a=A(a explicit) b=B(b default)
a=A(a companion) b=B(b explicit)

The surprise is, why did explicitly passing a affect how b was resolved, triggering the parameter default instead of resolving the given instance that’s in scope?

The incomplete Scala 3 language spec is yet to specify the behavior of given/using, and the Scala 3 page on using omits mention of this area. So I’m not 100% sure if this is intended behavior or a bug?

Seems like a bug… but if it’s intended, what is the rationale?

Scastie

2 Likes

To me this seems intended behavior. The Scala 2 behavior is the same:

A big difference is that in Scala 2 aMethod(b = new B("b explicit")) does not work.

In Scala 2, the default value of an implicit parameter works as a fallback if there is no implicit available. But if you explicitly invoke the implicit parameter list it stops behaving as an implicit parameter list, and starts behaving like any regular explicit parameter list.

In Scala 3, the implicit parameters keep behaving like implicit parameters even if some of them are provided explicitly, except suddenly the default values take precedence over any available implicit value. That seems even more confusing than the already confusing Scala 2 behavior. I also don’t know how to explain that to anyone other than “secretly it’s actually still the old Scala 2 implicits with a very thin layer of syntax”.

4 Likes

Can we intend something else, even if this is intended?

It’s easiest to think about if

foo(using bar)

is equivalent to

if true then
  given Bar = bar
  foo

and

def foo(using Baz, Bar = defaultBar)

is equivalent to (pseudocode)

{
  trait VeryVeryLowestPriorityImplicits:
    given Bar = defaultBar
  object EverythingElse extends VeryVeryLowestPrioritiyImplicits:
    export *.given
  import EverythingElse.given
  def foo(using Baz, Bar)
}
2 Likes

Well, since this behavior exists in Scala 2, it could be somehow specced or considered as so. Changing it may require a SIP. There was an exception like the recent given prioritization change.

The argument for not changing it is not to break people’s code that could depend on it. But changing it only for using and not implicit blocks could be acceptable.

Even if it is relied upon (hopefully we can check), with a migration warning, and a workaround which might be something like summon[compiletime.DefaultParameter] or compiletime.summonDefault, I think this change would be substantially less impactful than a lot of other non-SIP changes.

I agree that Scala 2 should not change–not because it wouldn’t make sense but because it’s basically in stasis and we can’t realistically push a warning through. And there’s no reason to change Scala 3 behavior if it’s labeled implicit, because eventually Scala 3 will presumably drop implicit anyway.

3 Likes

What seems surprising about this is that using parameters feel like they should not care about currying:

case class A(s: String)

object A:
  given A = new A("a companion")

case class B(s: String) 
object B:
  given B = new B("b companion")

def aMethod(using a: A, b: B = B("b default")) = s"a=$a b=$b"
def bMethod(using a: A)(using b: B = B("b default")) = s"a=$a b=$b"

aMethod(using a = new A("a explicit")) // b default

bMethod(using a = new A("a explicit")) // b companion
2 Likes

Because passing a explicitly means that you pass an explicit parameter list, not an inferred one. Then by the rules for explicit parameter lists the default is chosen for the second parameter. There is never a situation where some parameters in the same using clause are passed explicitly and others are synthesized by given resolution.

Im confused. How then is the other invocation in my example working?

aMethod(using b = new B("b explicit"))
prints
a=A(a companion) b=B(b explicit)

It seems a was resolved implicitly and b was explicitly specified, in the same using clause.

2 Likes

I had forgotten about that case. Indeed, when the compiler searches for default arguments for a using clause, it tries an implicit search as a fallback. So, yes there is an inversion between explicit and synthesized argument lists:

  • For a synthesized list, we try a default argument if no given can be constructed.
  • For an explicit argument list to a using clause we try to find a given if the argument is absent and there is no default argument either.

Nice puzzler! Somebody should start a Scala 3 puzzlers book.

But I don’t think we want to change it. First, it would be a semantic change and these are the most dangerous. Second, the current behavior has some logic behind it. In each case we try what’s obviously expected first (i.e. synthesize argument from givens, or fill in with default arguments) and fall back to the other thing if that does not work.

I disagree !

I would expect it to first look for a given, and then use the default value as a fall back
In the same way it works when curried:

This is true, in this case I feel like it’s probably niche enough no one would notice
(And that’s something we could check with the open community build)

3 Likes

My vote would be prioritize Scala 3 language specification :folded_hands: .. so Scala 3 is comparable with French rather than Creole :cat_with_wry_smile:

That’s how I would, and did, expect it to work. The other behavior was surprising and just doesn’t seem as useful to me.

I feel like we could “get away” with this change, given the community build feedback. As a Scala 3 adopter, feels like major changes drop regularly. Reordering of implicit selection, changes to context bound syntax, being recent examples.

1 Like

If there could be multiple implicit given using (ugh, third time lucky?) parameter lists, this wouldn’t be as much of an issue. This has been a feature request for at least 12 years…

Multiple using parameter lists certainly are possible in Scala 3.7, although there are some restrictions on how they can be invoked.

case class A(s: String) 
object A:
  given A = new A("a companion")

case class B(s: String) 
object B:
  given B = new B("b companion")

def aMethod(using a: A)(using b: B) = println(s"a=$a b=$b")

aMethod
aMethod(using new A("a explicit"))
aMethod(using new A("a explicit"))(using new B("b explicit"))

//Not permitted for some reason
//  "method aMethod in object Playground does not take more parameters"
//aMethod()(using new B("b explicit"))

I am finding it difficult to keep up with all the changes in Scala 3, and I’m not actually sure in what language version this was first supported.

scastie

I think the main reason for having multiple using lists, is so you can intersperse them with other (type-)parameter lists. I’m not sure you would ever need non-interspersed using lists if it wasn’t for this quirk.

Do we still need that for chained type inference, or can we do (using f: Foo, b: f.Bar) now?

That works. But you can also do this now:

def aMethod(using a: A)(b: a.B)(using c: b.C): c.type = c

aMethod(MyB())

scastie