Named Tuples as a library

I was thinking:
With a compile-time version of selectDynamic, we could get most named tuple features for free"
Well, it turns out we do have that: an inline selectDynamic

So here is an implementation of named pairs with a friendly syntax (for terms) and type safety:

import language.dynamics


@main def testPair: Unit =
  val john = P(age = 42, name = "John")

  john.age
  john.name

  //john.address // error: Field does not exist

  type Person = NamedPair["age", Int, "name", String]

  val bill: Person = (3, "Bill")

  val john2: Person = john

type LabelType = String & Singleton

// Named pair constructor
object P extends Dynamic:
  transparent inline def applyDynamicNamed[LA <: LabelType, A, LB <: LabelType, B, T <: Tuple](inline s: String)(inline a: (LA, A), b: (LB, B)): Any =
    inline s match
      case "apply" =>
        NamedPair[LA, A, LB, B](a._2, b._2)
      case _ =>
        compiletime.error(s"Method $s does not exist on P")


class NamedPair[LabelA <: LabelType, A, LabelB <: LabelType, B](val _1: A, val _2: B) extends Dynamic:

  transparent inline def selectDynamic(inline s: String) =
    inline s match
      case _ : LabelA => _1
      case _ : LabelB => _2
      case _ =>
        compiletime.error(s"Field $s does not exist on NamedPair")


given [LabelA <: LabelType, A, LabelB <: LabelType, B]: Conversion[(A, B), NamedPair[LabelA, A, LabelB, B]] = (a, b) => NamedPair(a, b)

We thus have a library handling all tuples of arities 2 to 22/11, which is a good start !

I tried making an implementation which can handle the remaining arities, but I was not good enough a tuple wrangling to make it work:

Unfold
import language.dynamics


type ExtractValue[P <: (String, ?)] = P match
  case (label, value) => value

type ExtractValues[T <: Tuple] = T match
  case p *: tail => ExtractValue[p] *: ExtractValues[tail]
  case EmptyTuple => EmptyTuple

type ExtractLabels[T <: Tuple] = T match
  case (label, value) *: tail => label *: ExtractLabels[tail]
  case EmptyTuple => EmptyTuple

object T extends Dynamic:
  transparent inline def applyDynamicNamed[T <: Tuple](inline s: String)(inline elements: T): Any =
    inline s match
      case "apply" =>
        def tmp[P](p: P): ExtractValue[P] = p match
              case (label, value) => value

        val fields: ExtractValues[T] = elements.map[ExtractValue]{[P] => (p: P) => tmp(p)}

        NamedTuple[ExtractLabels[T], ExtractValues[T]](fields)
      case _ =>
        compiletime.error("Unsupported method")


class NamedTuple[Labels <: Tuple, Values <: Tuple](val fields: Values) extends Dynamic:

  transparent inline def selectDynamic(inline s: String) =
    inline s match
      case valueOf[] =>
      case _ =>
        compiletime.error("Field does not exist")


@main def hello: Unit =
  val john = T(age = 42, name = "John")

  john.age
  john.name

  //john.address // error: Field does not exist

There might be a way to make NamedPair work as an alias, and get some kind of extension applyDynamicNamed, but I haven’t tried it yet

I have a strong feeling we can develop a strict, and easily macro-manipulable, type which can then be elegantly used by the user, and all with no or minimal addition to the language

5 Likes

You do have to do it the way you showed, with a new class, right now. I tried an alternative approach, but the bytecode isn’t cleaned up:

type LabelVal = String & Singleton

opaque infix type \[+A, L <: LabelVal] = A
object \ {
  import scala.language.dynamics

  inline def apply[A, L <: LabelVal](a: A): (A \ L) = a
  extension [A, L <: LabelVal](la: A \ L)
    inline def unlabel: A = la
    inline def apply(): Accessor[A, L] = Accessor(la)

  class Accessor[A, L <: LabelVal](private val la: A \ L) extends AnyVal with Dynamic {
    inline def selectDynamic(inline s: String): A =
      inline if s == compiletime.constValue[L] then unlabel(la)
      else compiletime.error("Label is " + compiletime.constValue[L] + " not " + s)
  }
}

Looks promising, but alas

class BytecodeCheck {
  // Should be no-op returning `s` as `String`
  def dynamiclabel(s: String \ "tag"): String =
    s().tag
}

gives

  public java.lang.String dynamiclabel(java.lang.String);
    Code:
       0: getstatic     #38                 // Field kse/basics/Abstractions$package$.MODULE$:Lkse/basics/Abstractions$package$;
       3: astore_2
       4: aload_2
       5: pop
       6: getstatic     #41                 // Field kse/basics/Abstractions$package$$bslash$.MODULE$:Lkse/basics/Abstractions$package$$bslash$;
       9: astore_3
      10: aload_2
      11: astore        4
      13: aload_1
      14: astore        5
      16: getstatic     #38                 // Field kse/basics/Abstractions$package$.MODULE$:Lkse/basics/Abstractions$package$;
      19: astore        6
      21: aload         5
      23: checkcast     #24                 // class java/lang/String
      26: astore        7
      28: getstatic     #44                 // Field kse/basics/Abstractions$package$$bslash$Accessor$.MODULE$:Lkse/basics/Abstractions$package$$bslash$Accessor$;
      31: aload         7
      33: invokevirtual #48                 // Method kse/basics/Abstractions$package$$bslash$Accessor$.inline$la$extension:(Ljava/lang/Object;)Ljava/lang/Object;
      36: checkcast     #24                 // class java/lang/String
      39: astore        8
      41: aload         8
      43: areturn

So either the optimizer needs to recognize and handle this case, or we need a marker trait InlineDynamic that can apply to any opaque type, or this is a language-level / really core library-level thing, not a user-space accessible feature.

1 Like

Small update:
We can of course use a singleton type as the “method name” !

   transparent inline def applyDynamicNamed
       [LA <: LabelType, A, LB <: LabelType, B, T <: Tuple]
-      (inline s: String)
+      (inline s: "apply")
       (inline a: (LA, A), b: (LB, B)): Any =
-    inline s match
-      case "apply" =>
-        NamedPair[LA, A, LB, B](a._2, b._2)
+    NamedPair[LA, A, LB, B](a._2, b._2)

Perhaps, you would be interested in record4s, which is an extensive record library for Scala 3.

GitHub: GitHub - tarao/record4s: Extensible records for Scala
docs: Extensible Records - record4s

3 Likes

That library looks extremely interesting, the one difference with my draft being the use of structural types, as opposed to an inline selectDynamic

Yes, I feel your approach is more type-oriented than record4s.

It turns out, you can use Dynamic for opaque types, for example:

opaque type NamedPair
    [LabelA <: LabelType, A, LabelB <: LabelType, B] <: Dynamic
    = (A, B) & Dynamic

Thanks to @Jasper-M who used it in Should extension methods works with Dynamic trait? - #2 by Jasper-M

Applying this change, the main method looks like this after each phase:
At the beginning:

val john = P(age = 42, name = "John")

println(john.age)

After erasure:

val john: Tuple2 =
  {
    val b$proxy1: Tuple2 = Tuple2.apply("name", "John")
    Tuple2.apply(Tuple2.apply("age", scala.Int.box(42))._2(),
      b$proxy1._2()):Tuple2
  }
println(
  {
    val $proxy1: Tuple.Pair$package =
      Tuple.Pair$package:Tuple.Pair$package
    val p$proxy1: Tuple2 = john:Tuple2
    p$proxy1._1()
  }
)

Adding back some syntactic sugar:

val john: Tuple2 = ( ("age", scala.Int.box(42))._2, ("name", "John")._2 )

println( john._1 )

It looks like the one thing missing is partial evaluation/constant folding of tuples

@Ichoran
I don’t know how to show the bytecode for it, but I guess it looks promising

I made a repo to host my experiments, you can see the updated example there

The reason for using structural-types, I imagine, is to have IDE support when using such record. With approaches based on types and inline selects and similar, the code becomes IDE hostile, you are constantly checking the record definition (the type) to remember what was the field called like. I’ve been using a named-tuples implementation similar the ones shown here for a while and when your records become large and mostly unknown to you (I auto generate them from openapi specs), it’s really terrible.

I didn’t know record4s but it looks interesting, I’ll have to see if I can make it work for my purposes

1 Like

Is there a (simple) manner to define a toString method on NamedPair? I tried

override def toString: String = s"P(${valueOf[LabelA]}=${_1},${valueOf[LabelB]}=${_2})"

but it does work for the paths to the Labels are not stable. See here for my attempt. I don’t know how to modify:

given [LabelA <: LabelType, A, LabelB <: LabelType, B]: Conversion[(A, B), NamedPair[LabelA, A, LabelB, B]] = (a, b) => NamedPair(a, b)

to get this to work.

BTW, in the old fashioned way, with

implicit def conv[LA <: LabelType, A, LB <: LabelType, B](x: (A, B))(using la: ValueOf[LA], lb: ValueOf[LB]): NamedPair[LA, A, LB, B] = NamedPair(x._1,x._2)(using la,lb)

there is no problem. I still find this Conversion thing hard to get right.

… a few moments later …

Okay, forget it. :face_with_open_eyes_and_hand_over_mouth: Completely unclear why i missed this simple solution:

given [LabelA <: LabelType, A, LabelB <: LabelType, B](using la: ValueOf[LabelA], lb: ValueOf[LabelB]): Conversion[(A, B), NamedPair[LabelA, A, LabelB, B]] = (a, b) => NamedPair(a, b)

Oh, that does look promising! It doesn’t quite work for singleton labels because the creation conversion boxes (but it’s not too bad–maybe the JIT compiler can figure out this is a no-op)?:

  // You can imagine what I did...Q is just the singleton version
  // and Meter is like Person except a singleton
  def mkq(d: Double): SporarumTuple.Meter =
    SporarumTuple.Q(meter = d)  
  public double mkq(double);
    Code:
       0: getstatic     #66                 // Field kse/basics/test/SporarumTuple$.MODULE$:Lkse/basics/test/SporarumTuple$;
       3: invokevirtual #82                 // Method kse/basics/test/SporarumTuple$.given_Conversion_A_NamedSingleton:()Lscala/Conversion;
       6: getstatic     #66                 // Field kse/basics/test/SporarumTuple$.MODULE$:Lkse/basics/test/SporarumTuple$;
       9: astore_3
      10: aload_3
      11: astore        4
      13: dload_1
      14: invokestatic  #88                 // Method scala/runtime/BoxesRunTime.boxToDouble:(D)Ljava/lang/Double;
      17: invokevirtual #93                 // Method scala/Conversion.apply:(Ljava/lang/Object;)Ljava/lang/Object;
      20: invokestatic  #97                 // Method scala/runtime/BoxesRunTime.unboxToDouble:(Ljava/lang/Object;)D
      23: dreturn

But the nice thing is that unlike with my encoding, reading is a no-op:

  def splabel(p: SporarumTuple.Person): String =
    p.name
  public java.lang.String splabel(scala.Tuple2<java.lang.Object, java.lang.String>);
    Code:
       0: getstatic     #66                 // Field kse/basics/test/SporarumTuple$.MODULE$:Lkse/basics/test/SporarumTuple$;
       3: astore_2
       4: aload_1
       5: astore_3
       6: aload_3
       7: invokevirtual #72                 // Method scala/Tuple2._2:()Ljava/lang/Object;
      10: checkcast     #24                 // class java/lang/String
      13: areturn

Could this be related to what I observed:

Well, it does mean that the tuples aren’t specialized, but that’s much less of a surprise than to have a primitive that gets a round of boxing for no reason. In a general tuple, everything that goes in has to be an object.

So I would say: no, not directly related. It’s the (generic) conversion that’s doing it.

1 Like

Note that on the type alias version this is actually impossible, because at runtime, a T(a = x, b = y)is really just a pair (x, y), there’s no place to store the a and the b.

So instead we need something like using ev: Show[NamedTuple[...]] where ev will store at run time the value of the labels

2 Likes