The "name in type" problem

Introduction

I recently learned of a feature/bug:

def foo(f: (x: Int) ?=> Int) = f(using 0)

foo(x.toString) // returns "0"

This seems like a neat feature, we don’t need to make these kinds of things anymore:

def ctx(using c: Context) = c

def foo(f: Context ?=> Int)

foo( ctx.iteration )

The issue is: The name of a function parameter is not supposed to matter !

val f: (x: Int) => x.type = (y: Int) => y

summon[((x: Int) ?=> x.type) =:= ((y: Int) ?=> y.type)] // works

Of course this divergence then creates issues:

type A = (x: Int) ?=> x.type
type B = (y: Int) ?=> y.type

def foo[T](y: T) = 0

summon[A =:= B] // works
// => A and B are equivalent, they can be swapped without effect

foo[A](x) // works

foo[B](x) // error: Not found: x
// They are not equivalent after all !

I think this perfectly illustrates what I will call the “name in type” problem
(see Using context function parameter by name (and not by type) - Question - Scala Users for more context)

The Problem

Sometimes, we name things in types because we to refer to them in the type itself, but we do not want this name to be relevant outside of it
This is the case with dependent types as highlighted above, and would be the case in qualified types (if they ever become part of the language)

Some other times, we name things in types because we want it to have some effect external to the type itself
In implicit functions, that name becomes available to refer to the given
In named tuples, the name becomes a constraint placed on types that should conform to it

These two different uses of names lead to surprising behavior like in the introduction, but not only:

val f: (a: (x: String, y: Int)) => ... = 
       (b: (x: String, y: Int)) => ...
//      ^ can be different, but x and y cannot

And the answer for these inconsistencies is “That’s the order in which the features came out, if we could go back, we would do things differently”

The solution

We are at a very unique point in time:

  • Named tuples are still experimental
  • The “implicit functions put their parameters in scope” bug was just discovered (or at least discovered to be a bug ? It’s not documented anywhere)
  • (qualified types are not part of the language)

This means we still have the opportunity to fix things !

Here are the 3 solutions that jump to me:

  1. All names in types are local: Drop named tuples and the bug
    • Unlikely: named tuples have already been accepted for implementation
  2. All names in types are global: (x: Int) => Int and (y: Int) => Int are no longer equivalent
    • Impossible: this would be break way too much existing code
  3. Differentiate local and global names through syntax, For example:
    • ((x: Int) => A) =:= ((y: Int) => B) but
    • ((x! Int) => A) =/= ((y! Int) => A)
    • Named tuples are written (x! Int, y! Int)
    • (x: Int) ?=> x.type does not put x in scope, but (x! Int) ?=> x.type does
    • I used the exclamation mark as it is similar to, but more “striking” than, the colon, but I doubt it would work in practice

We should not underestimate this problem, as I believe it will keep creeping up more and more as we add more expressive types
Furthermore, we should find a solution right now, as there will probably not be another opportunity to fix this ! :insert Scala 4 joke here:

5 Likes

To be honest, this seems like a footgun… Consider the following version of this:

// Library code

case class Context(iteration: Int)

def ctx(using c: Context) = c

def foo(f: (ctx: Context) ?=> Int) = f(using Context(1))

// User Code

val ctx: Context = Context(2)

foo(ctx.iteration)

(Scastie link)

On LTS, this returns 2 (val ctx shadows the def ctx), while on next it returns 1 (the named argument shadows ctx).

Now, you could say that both cases are bad, but at least on LTS it’s quite obvious what’s going on just by looking at the user code. On Next, it’s super confusing what’s shadowing what…

And with the new context functions, I don’t think having to add operations like def ctx(using c: Context) = c is that bad. A bit cumbersome for library authors, but users won’t even see that.

Note that even something explicit like ((x! Int) => A) wouldn’t really address this. This whole feature smells a bit like a bug to me.

All names in types are local: Drop named tuples and the bug

I’m not entirely sure why dropping this feature would imply dropping named tuples, though. But I’m not very familiar with that feature.

2 Likes

I’m making a larger point than this feature/bug, which I would also drop:

Why doesn’t this work?

def g(f: Int ?=> String) = f(using 0)
def huh = g(`contextual$1`.toString)

since that is

def huh: String = g((using contextual$1: Int) => `contextual$1`.toString)

I gave it backticks just in case.

Worth adding that the spec says the result is widened in the function type:

The type of the context function literal is scala.ContextFunctionN[S1, ...,Sn, T], where T is the widened type of e. T must be equivalent to a type which does not refer to any of the context parameters xi.

Live in the moment!

1 Like

Named tuples are no longer experimental since 3.7.x

Given that we already have named tuples, and also (a:A, b: B) >: (A, B) but (a2: A, b2: B) has no relationship with (a: B, b: B) save that they both have (A, B) as a subtype, I think we already know what the answer has to be if we want consistency in terms of types.

Right now, functions with argument names in types are considered as identical (by type, but not in the case of context parameters, in syntax) to functions without argument names. But functions are contravariant in input and covariant in output, so either we’re going to be in a state of permanent tension with named tuples (which are in), or (a: A) => B <: A => B because (a: A) >: A, and (a2: A) => B <: A => B but (a: A) => B and (a2: A) => B are two different incompatible subclasses of A => B.

That’s really the only choice.

What we do have a choice over is whether the instance (a: A) => a.toB is typed as A => B by default or (a: A) => B by default. There is good reason to choose the former: we don’t generally propagate specific identities in the case of path-dependent types unless we must. This is analogous.

However, I think we need to elevate functions with named arguments to proper full-fledged members of the type hierarchy. They are presently weird kind-of-hacky things. When they were the only game that looked like that, it was acceptable (although I’d tried to use them and run into various problems due to not being able to express their type fully), but with named tuples, I don’t think that cuts it any longer.

Okay, so, now we have the idea that the names of function arguments are not merely incidental but have consequences for the type.

Then the question is: if we have the names anyway, can we use them?

Here, I think the answer is suggested, but could be decided a different way, by the val f example. If you have val t: (x: String, y: Int) = ("eel", 2), then you can absolutely t.x. That’s the point! If you didn’t explicitly name the type? val u = t? u.x still works.

Suppose you have var f: (a: (x: String, y: Int)) => Int = .... It should be the case that f = _.x.length works. It doesn’t, right now, but if it’s def foo(f: (a: (x: String, y: Int)) => Int) then foo(_.x.length) does work. If you named the parameter a already, though, why does it have to be _? Why not a? _ means “whatever the parameter is”. But we know what the parameter is, because we named it: it’s a. Furthermore, if you have val g: (a: Int, b: String) => Int = _ * _.length then you can totally f(b = "eel", a = 2) to get 6. So it’s not like the names are of no consequence–you can reference them already.

I think the appropriate way to resolve this is to say: use functions with named arguments only when you mean it. If this causes confusion for the user, don’t do it that way. We probably need some extra syntax to allow us to express path-dependent typing in functions without leaking the names to external context, but because the names already leak in that you can reorder parameters by name, I think the default should be that the name is visible.

What is weird is that now it is visible for context parameters but not regular parameters.

So, overall, things would be the most self-consistent, I think, if (a: (x: String, y: Int)) => Int = a.x.length * a.y would just work, and if you don’t want to expose the a, then you (private a: (x: String, y: Int)) => a.x.type or whatever you need to do.

I don’t think this leads to the fewest surprises. But you could warn on shadowing and for foo(f: (x: Int, y: String) => Int) say

Warning: foo(x)
             ^
function parameter name x shadows x in scope.
Rename function arguments to resolve:
  foo((a, y) => a)  // use parameter
  foo((a, y) => x)  // use x from scope

The fact that parameter names in function types are interchangeable is consistent with methods at least.

val f: (a: Int) => String = (b: Int) => b.toString

trait Foo { def f(a: Int): String }
class Bar extends Foo { def f(b: Int) = b.toString }

val bar: Bar = new Bar
bar.f(b = 1) // "1"
(bar: Foo).f(a = 2) // "2"
1 Like

That’s also a good point. So I retract my statement about the first part being clear–nothing is clear because you can come up with different arguments for different behavior depending on which angle you look from.