Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Named tuples experimental first implementation #19075

Closed
wants to merge 15 commits into from

Conversation

odersky
Copy link
Contributor

@odersky odersky commented Nov 24, 2023

No docs yet, but here are some tests to show what is supported:

tests/run/named-tuples.scala

EDIT: Docs are now included.

@soronpo
Copy link
Contributor

soronpo commented Nov 24, 2023

Excited for this!

  1. Question: IIUC, differently named tuples with the same underlying types are still considered different types, right? Also between named and unnamed tuples? What is the reason that you chose this to be the rule instead of having all of them the same type?
    To me it seems that even if we say that the types are different, I could expect subtyping to work as if the named tuple is more specific than the unnamed tuple:
type MyTuple = (name: String, age: Int)
summon[MyTuple <:< Tuple2[String, Int]] 

If indeed subtyping exists, then we could actually refer to the named fields with old ._1 names.
2. Question: What should happen if I name a tuple with the original tuple names? Should it typecheck against an ordinary tuple?

type MyTuple = (_1: Int, _2: Int)
summon[MyTuple =:= Tuple2[Int, Int]] 

It could be that we should disallow to name thaem like that especially:

type MyTuple = (_2: Int, _1: Int) //wrong order
  1. Missing tests for named tuple compositions. Maybe we should also have dropNamesRecur that will recursively unname a composed named tuple.
  2. Question: Can we still use .apply(idx) to access an element of a named tuple?
  3. I do expect the addition of metaprogramming capability to consume and produce named tuples.

@odersky
Copy link
Contributor Author

odersky commented Nov 24, 2023

Question: IIUC, differently named tuples with the same underlying types are still considered different types, right? Also between named and unnamed tuples? What is the reason that you chose this to be the rule instead of having all of them the same type?
To me it seems that even if we say that the types are different, I could expect subtyping to work as if the named tuple is more specific than the unnamed tuple:

In fact, it's the opposite. Named tuples with different names are not in a subtype relation, but unnamed tuples are a subtype of named tuples. It's similar to named parameters: You can still pass an argument without a name by position, but you cannot pass a named argument to a parameter with a different name, just because the position matches. So this works:

type Person = (name: String, age: Int)

val bob: Person = ("Bob", 22)

But this doesn't:

val x: (String, Int) = bob

We can't have both relations because then names would not matter at all, and selection by name would not even be well-defined. And arguably the first relation is a lot more useful than the second. In fact the second relation is actively damaging, because bob's expansion to a tuple is

(NamedValue("name", String), NamedValue("age", 22))

and you don't want that to be equivalent to (String, 22) because that would mean that you could strip the important name info at any time.

  1. Question: What should happen if I name a tuple with the original tuple names? Should it typecheck against an ordinary tuple?

Maybe just disallow it since it would be genuinely confusing.

@odersky odersky marked this pull request as draft November 24, 2023 20:20
@soronpo
Copy link
Contributor

soronpo commented Nov 24, 2023

arguably the first relation is a lot more useful

Actually, I have a counter-argument. If I have in my codebase a definition that returns a tuple that I just want to name without immediately changing all the dependent codebase, that could have been useful in gradual adoption, IMO. Maybe not a strong argument in favor, but still is useful.

@odersky
Copy link
Contributor Author

odersky commented Nov 24, 2023

There's namedTuple.dropNames as an explicit conversion, though. Still some friction, I admit.

@soronpo
Copy link
Contributor

soronpo commented Nov 24, 2023

I recently had thoughts about the SIP process and I think any new proposal should define all possible feature interactions (and the implementations tested to cover them). I think feature interaction is where I find the Achilles' heel of newly introduced Scala features.
Although the type-system is composable, various exceptions and special cases can easily create a broken/undefined interaction.
After some more thought, here is where further tests could surface potential problems:

  • Type arguments: type Something[N, P] = (name: N, property: P)
  • Upper/Lower bounds [N, P, X <: Something[N, P]]
  • Definition overloading that return named tuples or have named tuple arguments.
  • reflect.Selectable producing a named tuple.
  • Implicit conversion (new/old style) to/from a named tuple
  • Implicit function types: (age: Age, name: Name) ?=> T
  • Match type disjointness between differently named tuples.

if elem > max then max = elem
(min = min, max = max)

val mm = minMax(1, 3, 400, -3, 10)
Copy link
Member

@bishabosha bishabosha Nov 25, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

will it still be possible to destructure like this? (and not need to call min.value?)

  val (min, max) = minMax(1, 3, 400, -3, 10)

or we have to do this?

  val (min = min, max = max) = minMax(1, 3, 400, -3, 10)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It should be supported, but is not yet implemented.

@bjornregnell
Copy link
Contributor

bjornregnell commented Nov 25, 2023

any new proposal should define all possible feature interactions

@soronpo
My experience from feature interaction in system development in general (I have been part of research projects in requirements engineering on this topic in the telecom domain together with Ericsson back in the days) is that the number of combinations quickly explodes.

The number of features in Scala is high, and the number of pairwise combinations (and triplets, quadruples, ...) is even higher, so to require "any" proposal" to investigate "all" possible feature interactions would effectively make it impractical to write a SIP.

It is good to think about feature interaction early on and we can include a section about that in the SIP template, but the requirement should be to investigate the most important/relevant/significant/critical interactions. It is better to leave the complete investigation to the design and implementation phase. Perhaps support from formal reasoning is even needed in the implementation phase...

@bjornregnell
Copy link
Contributor

bjornregnell commented Nov 25, 2023

@soronpo Maybe you want to kick off a thread on Contributors on the topic of "How to handle feature interaction in the SIP process" or similar and we can continue discussing it there? Or else we can discuss this at the next SIP meeting @anatoliykmetyuk

@odersky
Copy link
Contributor Author

odersky commented Nov 25, 2023

Feature interaction is a real concern. One way to keep it in check is a reductionist approach, which this proposal follows. We desugar named tuples into regular tuples with named elements, where named elements are just instances of an opaque type. After desugaring, there's almost nothing else to do. The one exception is pattern matching, so that has to be studied carefully. In particular I believe that there's synergy between this proposal and the named pattern matching SIP.

@soronpo
Copy link
Contributor

soronpo commented Nov 25, 2023

The number of features in Scala is high, and the number of pairwise combinations

Pairwise is enough, and you can easily eliminate the trivial ones. It's rare that I hit bugs that requires multiple new features to surface, but quite often I find bugs that could have been discovered by a "simple" feature coverage pairing process (e.g., my recent discovery of new style conversions and type classes).

@soronpo Maybe you want to kick off a thread on Contributors on the topic of "How to handle feature interaction in the SIP process" or similar and we can continue discussing it there? Or else we can discuss this at the next SIP meeting @anatoliykmetyuk

I will make sure it's added to the next meeting's agenda.

@bjornregnell
Copy link
Contributor

In particular I believe that there's synergy between this proposal and the named pattern matching SIP.

I would be really cool if this turns out to be a feasible unification!!

 - Always follow skolems to underlying types
 - Also follow other singletons to underlying types if normalize is true
Copy link
Contributor

@julienrf julienrf left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for exploring this idea!

Besides a couple of comments below, I think it also important to synthesize Conversions to compatible tuple types.

The test cases don’t show if Mirrors work on named tuples, but I would expect them to work just like they work on regular tuples. Would it be possible to provide access to the field names in the mirrors?

The fact that the following does not compile is a bit counter-intuitive:

val x: (String, Int) = (foo = "hello", x = 42)

Especially since both types have the same run-time representation.
Would you mind elaborating on “we can't have both relations because then names would not matter at all, and selection by name would not even be well-defined”?

In fact the second relation is actively damaging, because bob's expansion to a tuple is

(NamedValue("name", String), NamedValue("age", 22))

and you don't want that to be equivalent to (String, 22) because that would mean that you could strip the important name info at any time.

I am not sure that would be so dramatic, this happens all the time already when we upcast a value.

Last, it would be useful to hear from the maintainers of Iskra to see if they would see value in using this feature:

// currently
  measurements
    .groupBy($.stationId)
    .agg(
      min($.temperature).as("minTemperature"),
      max($.temperature).as("maxTemperature"),
      avg($.pressure).as("avgPressure")
    )
    .where($.maxTemperature - $.minTemperature < lit(20))
    .select($.stationId, $.avgPressure)
    .show()

// with this proposal
  measurements
    .groupBy($.stationId)
    .agg(
      (
        minTemperature = min($.temperature),
        maxTemperature = max($.temperature),
        avgPressure = avg($.pressure)
      )
    )
    .where($.maxTemperature - $.minTemperature < lit(20))
    .select($.stationId, $.avgPressure) // field names would be lost here?
    .show()

@@ -0,0 +1,9 @@
(Bob,33)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would it be possible to show the field names as well?

Suggested change
(Bob,33)
(name = Bob, age = 33)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not with .toString, since the representation of named and unnamed tuples is exactly the same.

But we really should have a standard Show typeclass that could handle these things.

bob match
case p @ (name = "Bob", age = _) => println(p.age)
bob match
case p @ (name = "Peter", age = _) => println(p.age)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it possible to use a variable pattern on the right-hand side of the = sign?

Suggested change
case p @ (name = "Peter", age = _) => println(p.age)
case (name = "Peter", age = age) => println(age)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes, the latest tests contain that pattern.

When matching a regular tuple pattern against a named tuple selector
we adapted the tuple to be named, which could hide errors. We now
adapt the selector to be unnamed instead.
@bishabosha
Copy link
Member

Besides a couple of comments below, I think it also important to synthesize Conversions to compatible tuple types.

There's the dropNames method which does what I think you mean - ie a tuple without labels

@odersky
Copy link
Contributor Author

odersky commented Nov 26, 2023

Would you mind elaborating on “we can't have both relations because then names would not matter at all, and selection by name would not even be well-defined”?

If you have both relations then at least conceptually any named tuple is compatible with any other named tuple (since subtyping is transitive), so what does it even mean to write x.age? The result would be a very fragile system where we somehow have to rely on transitivity not being fully implemented to make sense of these patterns. That's what C# and Typescript do, but I believe it's better not to follow them in this respect.

Also disallow checking a named pattern against a top type like Any or Tuple.
The names in a named pattern _must_ be statically visible in the selector type.
@julienrf
Copy link
Contributor

There's the dropNames method which does what I think you mean - ie a tuple without labels

I was more thinking of the ability to convert from, say, (name: String, age: Int, email: String) to (name: String, email: String).

@soronpo
Copy link
Contributor

soronpo commented Nov 27, 2023

@odersky I already came up with an additional feature that can rely on Named Tuples. Would love to get your view on it.
https://contributors.scala-lang.org/t/expanding-changing-selectable-based-on-upcoming-named-tuples-feature/6395

@sjrd
Copy link
Member

sjrd commented Nov 27, 2023

I find it very confusing that unnamed tuples would be subtypes of the named ones. To me it should be the other way around. A named tuple has a stronger contract than an unnamed tuple, since it assigns specific meaning, though names, to its various fields. A type with a stronger contract should be a subtype of a type with a weaker contract, according to LSP.

object Test:

object Named:
opaque type Named[name <: String & Singleton, A] >: A = A
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

>: A seems to only be there to support the ("bob", 42): (name: String, age: Int) conversion. This is what makes unnamed tuples subtypes of named ones (String, Int) <:< (name: String, age: Int).

An alternative might be to add the inverse of dropNames to make the conversion in the other direction.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With this encoding we can end up with the malformed (String, Tuple.NamedValue["age", Int]) (which represents (String, age: Int))

Therefore subtyping relation holds:
(String, Tuple.NamedValue["age", Int]) <:< (name: String, age: Int)

We might not have syntax for it but be might get that type through concatenation or inference.

@odersky
Copy link
Contributor Author

odersky commented Nov 27, 2023

Maybe an analogy with named parameters helps explain the subtyping rules? I can assign parameters by position to a named parameter list.

    def f(param: Int) = ...
    f(param = 1)   // OK
    f(2)          // Also OK

But I can't use a name to pass something to an unnamed parameter:

    val f: Int => T
    f(2)   // OK
    f(param = 2)   // Not OK

The rules for tuples are the same.

@nicolasstucki
Copy link
Contributor

We also have complications when pattern matching on named tuples

def foo(x: (name: String, age: Int)) = 
  x match
    case (name, _) =>
      name: String // error

  x match
    case name *: _ =>
      name: String // error

  val (name, _) = x
  name: String // error

@nicolasstucki
Copy link
Contributor

Similar issues with

def test(coord: (x: Int, y: Int)) =
  val coordList: List[Int] = coord.toList
  ...

@odersky
Copy link
Contributor Author

odersky commented Nov 28, 2023

@nicolasstucki yes, there's some discussion of this in the pos test. Not sure what to do. We could modify the operations to strip the named parts, but that risks making other operations weaker.

For instance:

def swap[A, B](pair: (A, B)): (B, A) = (pair(1), pair(0))

The way things are defined now,

swap((name = "Bob", age = 22)) = (age = 22, name = "Bob")

Arguably, that's useful.

There's also the problem that if we tweak apply, then it will behave different from _1, _2, _3.

So my tentative conclusion is that it's better not do do what "users expect" and instead keep the model simple and educate users what to expect.

@nicolasstucki
Copy link
Contributor

So my tentative conclusion is that it's better not do what "users expect" and instead keep the model simple and educate users what to expect.

Initially I also thought that this would be good enough. The issue is that when I tried to use more realistic use cases I noticed that we would need to use dropNames in too many places.

The issue I am worried about is that there are more methods that do not work than ones that do work.

Tuple method Behavior
Tuple.toArray
Tuple.toList
Tuple.toIArray
Tuple.:*
Tuple.*:
Tuple.++ ✔️
Tuple.size ✔️
Tuple.zip
Tuple.map
Tuple.take ✔️
Tuple.drop ✔️
Tuple.splitAt ✔️
Tuple.reverse ✔️
Tuple.toString ✔️
Tuple.apply
Tuple.head
Tuple.init ✔️
Tuple.last
Tuple.tail ✔️
Tuple2.swap ✔️
Tuple1._1
Tuple2._1
Tuple2._2
...
Tuple22._22
Tuple object method Description
Tuple.apply -
Tuple1.apply -
... -
Tuple22.apply -
Tuple.unapply
Tuple1.unapply
...
Tuple22.unapply
Tuple.fromArray -
Tuple.fromIArray -
Tuple.fromProduct -
Tuple.fromProductTyped -
EmptyTuple ✔️

@lrytz
Copy link
Member

lrytz commented Nov 28, 2023

Maybe instead of Tuple2[Named["name", String], Named["age", Int]], a type-level representation of NamedTuple[Tuple2[("name", Int), ("age", Int)]] with a conversion to Tuple2 could lead to fewer such leaks?

would it not make sense to add that to the standard library

@bmeesters I assume that's what Martin meant (not a language feature, but in the standard library). I agree projections and conversions from case classes are useful, but they will always allocate. I guess that's the tradeoff for named tuples, because the runtime representation is fixed, a field access can be efficiently compiled directly as such. Sturctural types are on the other end, no conversion required but field access has to go through reflection.

@odersky
Copy link
Contributor Author

odersky commented Nov 28, 2023

Maybe instead of Tuple2[Named["name", String], Named["age", Int]], a type-level representation of NamedTuple[Tuple2[("name", Int), ("age", Int)]] with a conversion to Tuple2 could lead to fewer such leaks?

It would also lead to a great duplication of the whole machinery we have for tuples (which is extensive). For instance, we have a lot of code for specializing tuples (and it's still not enough, by a large margin!). Do we want to duplicate all of that for named tuples? I think we'll likely never finish doing this.

@odersky
Copy link
Contributor Author

odersky commented Nov 28, 2023

We also have complications when pattern matching on named tuples

That's not what the spec says or what I see in the tests. I see:

def foo(x: (name: String, age: Int)) = 
  x match
    case (name, _) =>
      name: String // OK

  x match
    case name *: _ =>
      name: String // error

  val (name, _) = x
  name: String // OK (in fact, currently warning about refutable match, but this will be fixed)

@odersky
Copy link
Contributor Author

odersky commented Nov 28, 2023

The issue I am worried about is that there are more methods that do not work than ones that do work.

What does "not work" mean? For instance *: arguably works exactly like it should (by not stripping name info). zip and map should be adapted to keep name info. ++ should be adapted to check that operands are either both named or both unnamed. Or else, maybe drop name info from one operand if the other is unnamed.

The unapply methods work correctly in patterns. So the only methods remaining are the toList, toArray, toIArray methods. We have to specify what we want here, but I believe it would be reasonable to let them (as the only methods!) strip names.

@odersky
Copy link
Contributor Author

odersky commented Nov 28, 2023

A possible improvement is to make named tuple elements easier to deal with in isolation. We already have a good apply method, but we can also add an unapply:

  object Element:
    def apply[S <: String & Singleton, A](name: S, x: A): Element[name.type, A] = x

    inline def unapply[S <: String & Singleton, A](named: Element[S, A]): Some[(S, A)] =
      Some((compiletime.constValue[S], named))

With that, we can write code like this one:

  import NamedTuple.Element

  val Element(nameStr, n) *: Element(ageStr, a) *: EmptyTuple = bob
  println(s"matched elements ($nameStr, $n), ($ageStr, $a)")

  val Element(ageStr1, age) = bob(1)
  assert(ageStr1 == "age" && age == 33)

 - Add unapply method to NamedTuple.Element
 - Avoid spurious refutability warning when matching a
   named tuple RHS against an unnamed pattern.
@nicolasstucki
Copy link
Contributor

nicolasstucki commented Nov 28, 2023

Maybe instead of Tuple2[Named["name", String], Named["age", Int]], a type-level representation of NamedTuple[Tuple2[("name", Int), ("age", Int)]] with a conversion to Tuple2 could lead to fewer such leaks?

It would also lead to a great duplication of the whole machinery we have for tuples (which is extensive). For instance, we have a lot of code for specializing tuples (and it's still not enough, by a large margin!). Do we want to duplicate all of that for named tuples? I think we'll likely never finish doing this.

We could also try NamedTuple[Tuple2[("name", "age"), (String, Int)]] . In this case, duplicating the tuple machinery does not look that expensive if we use inline operations.

// Almost complete implementation of NamedTuple[Nme <: Tuple, Tup <: Tuple]
object NamedTuples:
  import Tuple.*
  opaque type NamedTuple[Nme <: Tuple, Tup <: Tuple] = Tup

  extension [Nme <: Tuple, Tup <: Tuple](tup: NamedTuple[Nme, Tup])
    inline def dropNames: Tup = tup

    inline def toList: List[Union[Tup]] = (tup: Tup).toList
    inline def toArray: Array[Object] = tup.toArray
    inline def toIArray: IArray[Object] = tup.toIArray

    inline def ++[Nme2 <: Tuple, Tup2 <: Tuple](that: NamedTuple[Nme2, Tup2]): NamedTuple[Concat[Nme, Nme2], Concat[Tup, Tup2]] = tup ++ that
    // inline def :* [L] (x: L): NamedTuple[Append[Nme, ???], Append[Tup, L] = ???
    // inline def *: [H] (x: H): NamedTuple[??? *: Nme], H *: Tup] = ???

    inline def size: Size[Tup] = tup.size

    inline def map[F[_]](f: [t] => t => F[t]): NamedTuple[Nme, Map[Tup, F]] = (tup: Tup).map(f)

    inline def take(n: Int): NamedTuple[Take[Nme, n.type], Take[Tup, n.type]] = tup.take(n)
    inline def drop(n: Int): NamedTuple[Drop[Nme, n.type], Drop[Tup, n.type]] = tup.drop(n)
    inline def splitAt(n: Int): NamedTuple[Split[Nme, n.type], Split[Tup, n.type]] = tup.splitAt(n)

    inline def reverse: NamedTuple[Reverse[Nme], Reverse[Tup]] = tup.reverse

    inline def zip[Tup2 <: Tuple](that: NamedTuple[Nme, Tup2]): NamedTuple[Nme, Zip[Tup, Tup2]] =
      tup.zip(that) // note that this zips only if names are equal
  end extension

  extension [Nme <: NonEmptyTuple, Tup <: NonEmptyTuple](tup: NamedTuple[Nme, Tup])
    inline def apply(n: Int): Elem[Tup, n.type] = tup(n)
    inline def head: Head[Tup] = tup.head
    inline def last: Last[Tup] = tup.last
    inline def tail: NamedTuple[Tail[Nme], Tail[Tup]] = tup.tail
    inline def init: NamedTuple[Init[Nme], Init[Tup]] = tup.init
  end extension

  // def unapply[H, T <: Tuple](x: H *: T): (H, T) = (x.head, x.tail)
import NamedTuples.*

def test(x: NamedTuple[("name", "age"), (String, Int)], y: NamedTuple[("user", "email"), (String, String)]) =
  x.head : String
  x.last : Int
  x(0) : String
  x(1) : Int
  x ++ y :  NamedTuple[("name", "age", "user", "email"), (String, Int, String, String)]
  x.toList : List[Int | String]
  x.head : String
  x.tail : NamedTuple["age" *: EmptyTuple, Int *: EmptyTuple]
  x.last : Int
  x.init : NamedTuple["name" *: EmptyTuple, String *: EmptyTuple]

We do have some optimization for tuples that are not identified because of some extra asInstanceOfs. This does not seem to be an issue as we should support it anyway for other generic tuple code that involves inlined opaque types.

We could also use

- opaque type NamedTuple[Nme <: Tuple, Tup <: Tuple] = Tup
+ opaque type NamedTuple[Nme <: Tuple, Tup <: Tuple] >: Tup = Tup

to make this conversion work

val _: NamedTuple[("name", "age"), (String, Int)] = ("bob", 42)

@odersky
Copy link
Contributor Author

odersky commented Nov 28, 2023

We could also try NamedTuple[Tuple2[("name", "age"), (String, Int)]] . In this case, duplicating the tuple machinery does not look that expensive if we use inline operations.

That's an interesting idea (and matches what @lrytz proposed). I might have been too concerned about duplication, maybe that's manageable.

It's just a tiny change, once we have named tuples.
@odersky
Copy link
Contributor Author

odersky commented Nov 28, 2023

@nicolasstucki This looks interesting! I guess my main question is how easy or hard would it be to define new generic operations over named tuples. As a start, how would we implement and name the analogue of *:?

@nicolasstucki
Copy link
Contributor

nicolasstucki commented Nov 29, 2023

@nicolasstucki This looks interesting! I guess my main question is how easy or hard would it be to define new generic operations over named tuples. As a start, how would we implement and name the analogue of *:?

In my example encoding, I managed to encode that operation with this extension

opaque type NamedTuple[Nme <: Tuple, Tup <: Tuple] = Tup
extension [Nme <: Tuple, Tup <: Tuple](tup: NamedTuple[Nme, Tup])
  ...
  inline def apply[N <: String & Singleton]: NamedAddition[N, Nme, Tup] = tup
end extension
extension [Nme <: NonEmptyTuple, Tup <: NonEmptyTuple](tup: NamedTuple[Nme, Tup])
  // inline def apply(n: Int): Elem[Tup, n.type] = tup(n) // removed to make the other apply work when only type parameters are passed
  ...
end extension

opaque type NamedAddition[N <: String & Singleton, Nme <: Tuple, Tup <: Tuple] = Tup
extension [N <: String & Singleton, Nme <: Tuple, Tup <: Tuple](tup: NamedAddition[N, Nme, Tup])
  inline def *:[H] (x: H): NamedTuple[N *: Nme, H *: Tup] = x *: tup
  inline def :*[L] (x: L): NamedTuple[Append[Nme, N], Append[Tup, L]] = tup :* x
end extension
def test(tup: NamedTuple[("name", "age"), (String, Int)]) =
  tup ["user"]*: "bob1"
  tup ["user"]:* "bob2"

  tup["user"] *: "bob1"
  tup["user"] :* "bob2"

I also tied tup *:["user"] "bob1" but the parser complained with expression expected but '[' found.

Details
  extension [Nme <: Tuple, Tup <: Tuple](tup: NamedTuple[Nme, Tup])
    ...
    inline def :*[N <: String & Singleton]: NamedAppend[N, Nme, Tup] = tup
    inline def *:[N <: String & Singleton]: NamedAppend[N, Nme, Tup] = tup


  opaque type NamedPrepend[N <: String & Singleton, Nme <: Tuple, Tup <: Tuple] = Tup
  object NamedPrepend:
    extension [N <: String & Singleton, Nme <: Tuple, Tup <: Tuple](tup: NamedPrepend[N, Nme, Tup])
      inline def apply[H] (x: H): NamedTuple[N *: Nme, H *: Tup] = (x *: tup).asInstanceOf[NamedTuple[N *: Nme, H *: Tup]]

  opaque type NamedAppend[N <: String & Singleton, Nme <: Tuple, Tup <: Tuple] = Tup
  object NamedAppend:
    extension [N <: String & Singleton, Nme <: Tuple, Tup <: Tuple](tup: NamedAppend[N, Nme, Tup])
      inline def apply[L] (x: L): NamedTuple[Append[Nme, N], Append[Tup, L]] = (tup :* x).asInstanceOf[NamedTuple[Append[Nme, N], Append[Tup, L]]]
def test(tup: NamedTuple[("name", "age"), (String, Int)]) =
  tup *:["user"] "bob1" // error: expression expected but '[' found
  tup :*["user"] "bob2" // error: expression expected but '[' found

@odersky
Copy link
Contributor Author

odersky commented Nov 29, 2023

Great. Here's another challenge: We need to express an operation t1 updateWith t2 where t1 and t2 are named tuples.
The elements should be the one from t1 , followed by the elements of t2. Except if an element in t1 has the same name as an element in t2, in which case the value in t2 should replace the corresponding value coming from t1 and the element should not be added again. How do I express that (in either system)?

@bishabosha
Copy link
Member

Great. Here's another challenge: We need to express an operation t1 updateWith t2 where t1 and t2 are named tuples.
The elements should be the one from t1 , followed by the elements of t2. Except if an element in t1 has the same name as an element in t2, in which case the value in t2 should replace the corresponding value coming from t1 and the element should not be added again. How do I express that (in either system)?

like this?

val bob = (name = "Bob", age = 23)
val scala = (age = 19, platforms = List("jvm", "js", "native"))
val bobScala = bob updateWith scala
assert(bobScala == (name = "Bob", age = 19, platforms = List("jvm", "js", "native")))

@odersky
Copy link
Contributor Author

odersky commented Nov 29, 2023

@bishabosha Yes, exactly.

Shows how UpdateWith on named tuples can be implemented on the tpe level.
@odersky
Copy link
Contributor Author

odersky commented Nov 30, 2023

Just pushed a test that implements UpdateWith at the type level. Value level is still missing, and I could use some help there.

So far I have the impression that a NamedTuple with split name and value tuples is not too burdensome to implement these ops. So I am going to switch the representation to that one.

@bishabosha
Copy link
Member

bishabosha commented Nov 30, 2023

I had a go with the old encoding of named tuples - just that it doesn't handle two labels of the same name but different type:

import scala.language.experimental.namedTuples
import quoted.*

extension [Ts <: Tuple](ts: Ts)
  infix inline def updateWith[Us <: Tuple](us: Us): Ops.UpdateWith[Ts, Us] =
   // probably could optimise to unique integer keys across both tuples
    val tsLabels = LabelledWitness.resolveLabels[Ts]
    val usLabels = LabelledWitness.resolveLabels[Us]
    // use ListMap to preserve insertion order
    val tsMap = tsLabels.iterator.zip(ts.productIterator).to(collection.immutable.ListMap)
    val merged = usLabels.iterator.zip(us.productIterator).foldLeft(tsMap):
      case (acc, (label, elem)) => acc.updated(label, elem)
    val values = merged.valuesIterator.toArray
    Tuple.fromArray(values).asInstanceOf[Ops.UpdateWith[Ts, Us]]

object LabelledWitness:

  inline def resolveLabels[Ts <: Tuple]: List[String] = ${ resolveLabelsImpl[Ts] }

  def resolveLabelsImpl[Ts <: Tuple: Type](using Quotes): Expr[List[String]] =
    import quotes.reflect.*
    val namedTupleElement = Symbol.requiredModule("scala.NamedTuple").typeMember("Element")

    def inner[Ts <: Tuple: Type](acc: List[String]): List[String] =
      Type.of[Ts] match
        case '[EmptyTuple] => acc
        case '[t *: tail] => TypeRepr.of[t] match
          case AppliedType(ref, List(ConstantType(StringConstant(s0)), _)) if ref.typeSymbol == namedTupleElement =>
            inner[tail](acc :+ s0)
          case _ =>
            report.errorAndAbort(s"Expected NamedTuple.Element, got: ${Type.show[t]}")
    Expr(inner[Ts](Nil))

object Ops:
  type Contains[Needle, Haystack <: Tuple] <: Boolean = Haystack match
    case EmptyTuple => false
    case Needle *: ts => true
    case t *: ts => Contains[Needle, ts]

  type UpdateWith[Acc <: Tuple, Explore <: Tuple] <: Tuple = Explore match
    case EmptyTuple => Acc
    case t *: ts    => Contains[t, Acc] match
      case true  => UpdateWith[Acc, ts]
      case false => UpdateWith[Tuple.Append[Acc,t], ts]

test case in a separate file:

val bob = (name = "Bob", age = 23)
val scalaLang = (age = 19, platforms = List("jvm", "js", "native"))
val bobScala = bob updateWith scalaLang


@main def Test() =
  assert(bobScala == (name = "Bob", age = 19, platforms = List("jvm", "js", "native")))

@odersky
Copy link
Contributor Author

odersky commented Dec 3, 2023

#19174 implements the suggestion by @lrytz and @nicolasstucki. Overall, I think I prefer that version.

@odersky
Copy link
Contributor Author

odersky commented Dec 12, 2023

Closed in favor of #19174

@odersky odersky closed this Dec 12, 2023
@LPTK
Copy link
Contributor

LPTK commented Dec 13, 2023

@odersky

If you have both relations then at least conceptually any named tuple is compatible with any other named tuple (since subtyping is transitive)

Just a periodic reminder that Scala subtyping is not transitive (notably due to path-dependent types selecting abstract types with both upper and lower bounds) and that this relation would not have to be transitive either.

In which situations would you foresee a problem?

so what does it even mean to write x.age?

It means what the type of x says it means. This is already the case today: x.age may mean things like x.selectDynamic("age") depending on the type of x... and it also completely loses its meaning if we narrow x to the subtype Nothing.

@odersky
Copy link
Contributor Author

odersky commented Dec 13, 2023

One problem I would see is here:

val x: (name: String, age: Person)
val y: (String, Person)
val z = if ??? then x else y

What is the type of z?

@LPTK
Copy link
Contributor

LPTK commented Dec 13, 2023

One could decree that the LUB should go either way, and as long as it's done consistently I don't see how that could lead to an issue.

Here, intuitively I'd say z should have type (String, Person).

But TypeScript (which also subtypes in both directions) happens to think the other way:

function lub(b: boolean, x: [name: String, age: number], y: [String, number]) { return b ? x : y }
// function lub(b: boolean, x: [name: String, age: number], y: [String, number]): [name: String, age: number]

@odersky
Copy link
Contributor Author

odersky commented Dec 13, 2023

There are no specific rules for disambiguating LUBs, and I would think we prefer if we could keep it that way. I also prefer Typescript's behavior. You have the names, why throw them away?

In summary my tendency is to keep the subtyping unnamed <: named and complement it with an implicit conversion named -> unnamed.

odersky added a commit that referenced this pull request May 7, 2024
This implementation follows the alternative representation scheme, where
a named tuple type is represented as a
pair of two tuples: one for the names, the other for the values. 

Compare with #19075, where named tupes were regular types, with special
element types that combine name and value.

In both cases, we use an opaque type alias so that named tuples are
represented at runtime by just their values - the names are forgotten.

The new implementation has some advantages

- we can control in the type that named and unnamed elements are not
mixed,
 - no element types are leaked to user code,
- non-sensical operations such as concatenating a named and an unnamed
tuple are statically excluded,
- it's generally easier to enforce well-formedness constraints on the
type level.

The main disadvantage compared to #19075 is that there is a certain
amount of duplication in types and methods between `Tuple` and
`NamedTuple`. On the other hand, we can make sure by this that no
non-sensical tuple operations are accidentally applied to named tuples.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.