Skip to content

Add experimental NamedTuple copyFrom method #23135

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

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 45 additions & 3 deletions library/src/scala/NamedTuple.scala
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
package scala
import compiletime.ops.boolean.*
import compiletime.ops.int.*
import scala.annotation.experimental
import scala.annotation.implicitNotFound

object NamedTuple:

Expand All @@ -25,10 +28,10 @@ object NamedTuple:
extension [V <: Tuple](x: V)
inline def withNames[N <: Tuple]: NamedTuple[N, V] = x

import NamedTupleDecomposition.{Names, DropNames}
import NamedTupleDecomposition.{Names, DropNames, Decompose}
export NamedTupleDecomposition.{
Names, DropNames,
apply, size, init, head, last, tail, take, drop, splitAt, ++, map, reverse, zip, toList, toArray, toIArray
Names, DropNames, Decompose,
apply, size, init, head, last, tail, take, drop, splitAt, ++, copyFrom, map, reverse, zip, toList, toArray, toIArray
}

extension [N <: Tuple, V <: Tuple](x: NamedTuple[N, V])
Expand Down Expand Up @@ -116,6 +119,14 @@ object NamedTuple:
case Names[Y] =>
NamedTuple[Names[X], Tuple.Zip[DropNames[X], DropNames[Y]]]

@experimental
type Copy[X <: AnyNamedTuple, Y <: AnyNamedTuple] = (Decompose[X], Decompose[Y]) match
case ((nx, vx), (ny, vy)) =>
NamedTuple[
nx,
NamedTupleDecomposition.Copy0[nx, vx, ny, vy, EmptyTuple]
]

/** A type specially treated by the compiler to represent all fields of a
* class argument `T` as a named tuple. Or, if `T` is already a named tuple,
* `From[T]` is the same as `T`.
Expand Down Expand Up @@ -174,6 +185,18 @@ object NamedTupleDecomposition:
: Concat[NamedTuple[N, V], NamedTuple[N2, V2]]
= x.toTuple ++ that.toTuple

/** The named tuple consisting of all elements of this tuple, with fields replaced by those from `that`.
* The field names of the update tuple of updates must all be present in this tuple, but not all fields are required.
*/
@experimental
inline def copyFrom[N2 <: Tuple, V2 <: Tuple](that: NamedTuple[N2, V2])(using Tuple.ContainsAll[N2, N] =:= true)
: Copy[NamedTuple[N, V], NamedTuple[N2, V2]]
= scala.runtime.Tuples.copy(
self = x.toTuple,
that = that.toTuple,
indices = compiletime.constValueTuple[Tuple.Indices[N, N2]]
).asInstanceOf[Copy[NamedTuple[N, V], NamedTuple[N2, V2]]]

/** The named tuple consisting of all element values of this tuple mapped by
* the polymorphic mapping function `f`. The names of elements are preserved.
* If `x = (n1 = v1, ..., ni = vi)` then `x.map(f) = `(n1 = f(v1), ..., ni = f(vi))`.
Expand Down Expand Up @@ -205,10 +228,29 @@ object NamedTupleDecomposition:

end extension

@experimental
type LookupName[X, N <: Tuple, V <: Tuple] <: Option[Any] =
(N, V) match
case (X *: _, v *: _) => Some[v]
case (_ *: ns, _ *: vs) => LookupName[X, ns, vs]
case (EmptyTuple, EmptyTuple) => None.type

@experimental
type Copy0[Nx <: Tuple, Vx <: Tuple, Ny <: Tuple, Vy <: Tuple, Acc <: Tuple] <: Tuple =
(Nx, Vx) match
case (nx *: nxs, vx *: vxs) => LookupName[nx, Ny, Vy] match
case Some[vy] => Copy0[nxs, vxs, Ny, Vy, vy *: Acc]
case _ => Copy0[nxs, vxs, Ny, Vy, vx *: Acc]
case (EmptyTuple, EmptyTuple) => Tuple.Reverse[Acc]

/** The names of a named tuple, represented as a tuple of literal string values. */
type Names[X <: AnyNamedTuple] <: Tuple = X match
case NamedTuple[n, _] => n

/** The value types of a named tuple represented as a regular tuple. */
type DropNames[NT <: AnyNamedTuple] <: Tuple = NT match
case NamedTuple[_, x] => x

@experimental
type Decompose[NT <: AnyNamedTuple] <: Tuple = NT match
case NamedTuple[n, x] => (n, x)
27 changes: 27 additions & 0 deletions library/src/scala/Tuple.scala
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package scala
import annotation.showAsInfix
import compiletime.*
import compiletime.ops.int.*
import scala.annotation.experimental

/** Tuple of arbitrary arity */
sealed trait Tuple extends Product {
Expand Down Expand Up @@ -281,6 +282,32 @@ object Tuple {
case false => Disjoint[xs, Y]
case EmptyTuple => true

@experimental
type ContainsAll[X <: Tuple, Y <: Tuple] <: Boolean = X match
case x *: xs => Contains[Y, x] match
case true => ContainsAll[xs, Y]
case false => false
case EmptyTuple => true

@experimental
type IndexOfOptionOnto[X, N <: Tuple, Acc <: Int] <: Option[Int] = N match
case X *: _ => Some[Acc]
case _ *: ns => IndexOfOptionOnto[X, ns, S[Acc]]
case EmptyTuple => None.type

@experimental
type IndexOfOption[X, N <: Tuple] = IndexOfOptionOnto[X, N, 0]

@experimental
type Indices[X <: Tuple, Y <: Tuple] = IndicesOnto[X, Y, 0, EmptyTuple]

@experimental
type IndicesOnto[X <: Tuple, Y <: Tuple, Idx <: Int, Acc <: Tuple] <: Tuple = X match
case x *: xs => IndexOfOption[x, Y] match
case Some[i] => IndicesOnto[xs, Y, S[Idx], i *: Acc]
case _ => IndicesOnto[xs, Y, S[Idx], (-1 * S[Idx]) *: Acc] // no problem if Int overflow
case EmptyTuple => Reverse[Acc]

/** Empty tuple */
def apply(): EmptyTuple = EmptyTuple

Expand Down
17 changes: 17 additions & 0 deletions library/src/scala/runtime/Tuples.scala
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package scala.runtime

import scala.annotation.experimental

object Tuples {

inline val MaxSpecialized = 22
Expand Down Expand Up @@ -283,6 +285,21 @@ object Tuples {
case self: Product => self.productArity
}

@experimental
def copy(self: Tuple, that: Tuple, indices: Tuple): Tuple = indices match
case EmptyTuple => self
case _ =>
val is = indices.productIterator.asInstanceOf[Iterator[Int]]
val arr = IArray.from(
is.map: i =>
if i < 0 then
val i0 = Math.abs(i) - 1 // nice that it is correct even for min value
self.productElement(i0)
else
that.productElement(i)
)
Tuple.fromIArray(arr)

// Tail for Tuple1 to Tuple22
private def specialCaseTail(self: Tuple): Tuple = {
(self: Any) match {
Expand Down
3 changes: 3 additions & 0 deletions project/MiMaFilters.scala
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ object MiMaFilters {
// Scala.js-only class
ProblemFilters.exclude[FinalClassProblem]("scala.scalajs.runtime.AnonFunctionXXL"),
ProblemFilters.exclude[DirectMissingMethodProblem]("scala.scalajs.runtime.AnonFunctionXXL.this"),

// NamedTuples copy method
ProblemFilters.exclude[DirectMissingMethodProblem]("scala.runtime.Tuples.copy")
),

// Additions since last LTS
Expand Down
16 changes: 16 additions & 0 deletions tests/neg/named-tuple-ops-copy.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
//> using options -experimental

trait Mod

given Conversion[String, Mod] = _ => new Mod {}

type Foo = (name: String, mod: Mod)
case class Foo0(name: String, mod: Mod)

@main def Test =
val foo: Foo = (name = "foo", mod = "some_mod")
val foo_updated: Foo = foo.copyFrom((mod = "bar")) // error, stays as String
Copy link
Member Author

Choose a reason for hiding this comment

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

here is a demo of the limitation of no target typing, which I guess could be mitigated with a dedicated feature, or a secondary library solution based on lenses



val foo0: Foo0 = Foo0(name = "foo", mod = "some_mod")
val foo0_updated: Foo0 = foo0.copy(mod = "bar") // ok - does the conversion
16 changes: 15 additions & 1 deletion tests/run-tasty-inspector/stdlibExperimentalDefinitions.scala
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,21 @@ val experimentalDefinitionInLibrary = Set(
"scala.Predef$.runtimeChecked", "scala.annotation.internal.RuntimeChecked",

// New feature: SIP 61 - @unroll annotation
"scala.annotation.unroll"
"scala.annotation.unroll",

// New experimental method - copyFrom in NamedTuple.scala (and supporting definitions)
"scala.NamedTuple$.copyFrom", "scala.NamedTuple$.Copy",
"scala.NamedTuple$.Decompose",
"scala.NamedTupleDecomposition$.Copy0",
"scala.NamedTupleDecomposition$.Decompose",
"scala.NamedTupleDecomposition$.LookupName",
"scala.NamedTupleDecomposition$.copyFrom",
"scala.Tuple$.ContainsAll",
"scala.Tuple$.IndexOfOption",
"scala.Tuple$.IndexOfOptionOnto",
"scala.Tuple$.Indices",
"scala.Tuple$.IndicesOnto",
"scala.runtime.Tuples$.copy",
)


Expand Down
29 changes: 29 additions & 0 deletions tests/run/named-tuple-ops-copy.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
//> using options -experimental

type City = (name: String, zip: Int, pop: Int)
type Coord = (x: Double, y: Double)
type Labels = (x: String, y: String)

@main def Test =
val city: City = (name = "Lausanne", zip = 1000, pop = 140000)
val coord: Coord = (x = 1.0, y = 0.0)
val labels: Labels = (x = "west", y = "north")

// first field updated
val coord_update = coord.copyFrom((x = 2.0))
val _: Coord = coord_update
assert(coord_update.x == 2.0 && coord_update.y == 0.0)

// last field updated
val city_update = city.copyFrom((pop = 150000))
val _: City = city_update
assert(city_update.name == "Lausanne" && city_update.zip == 1000 && city_update.pop == 150000)

// replace field types
val coord_to_labels = coord.copyFrom((x = "east", y = "south"))
val _: Labels = coord_to_labels
assert(coord_to_labels.x == "east" && coord_to_labels.y == "south")

// out of order
val city_update2 = city.copyFrom((pop = 150000, name = "Lausanne", zip = 1000))
val _: City = city_update2
Loading