This is a really good set of examples, but I think we also should see how close we can get without explicit compiler support to determine whether explicit compiler support is desirable (and also to see if we can play with some of the concepts absent a compiler).
Being able to tag anything with a string constant type refinement is I think a fundamental generalization of named tuples. This was Martinâs earlier implementation method, and I return to that (essentially) here, with no subtyping relationships.
Iâve tossed together a hasty and brittle but working implementation into my library so people can play with it if they want to. (I want to.) The implementation only works for small sizes of tuples, because each time I ran into some problem that surprised me, I just adopted a simpler strategy which scaled less well. Anyway, you need a 3.4 flavor to try it out. The following at the top of a .scala
file run with scala-cli
will do the trick:
//> using scala 3.4.0-RC1-bin-20231114-18ada51-NIGHTLY
//> using dep com.github.ichoran::kse3-basics:0.2.7
//> using mainClass example.Main
package example
import scala.language.experimental.relaxedExtensionImports
import kse.basics.{given, _}
import kse.basics.labels.{given, _}
The key idea in this implementation is that you can take any type, letâs say Boolean
, and annotate it with a string constant using \
, e.g. Boolean \ "read-only"
. This is an opaque type that does nothing but apply the string literal as a type parameter. This works for values as well as types, so true \ "read-only"
is a Boolean \ "read-only"
with a value of true
.
Now, we can consider the case of fully-labeled tuples, e.g. (String \ "name", Int \ "age")
. One can write extension methods for such types specifically (extension [A, La, B, Lb, ...](tuple: (A \ La, B \ Lb, ...))
), so I did.
Multiple-result methods
This immediately solves the multiple-result methods issue. It also allows one to force disambiguation of parameters:
def mutate(s: Array[Byte] \ "source", d: Array[Byte] \ "dest") = ...
mutate(xs, ys) // Fails
mutate(xs \ "dest", ys \ "source") // Fails
mutate(xs \ "source", ys \ "dest") // Succeeds
One has the same decision about subtyping of A
vs A \ "label"
, but note that in the case of function returns one can write a method that conforms to the desired labels (I call it .label
) so it isnât much work.
Enabler for pattern matching
Full pattern matching isnât something Iâve been able to get working smoothly. But you can have pattered extractors quite easily.
If you define a method ~
that accesses a labeled value by label, then you have assurance at use-site that you know what it is:
def foo(ro: Boolean \ "read-only") =
if ro ~ "read-only" then ???
This can be extended to extract one (~
) or many (~~
) items from a named tuple.
val stuff = (5 \ "eel", true \ "salmon", 'b' \ "bass")
val salmon = stuff ~ "salmon" // true
val (eel, salmon, bass) = stuff ~~ ("eel", "salmon", "bass")
Itâs not quite as nice as pattern matching, but itâs pretty decent.
Computed intermediate types
With every item labelled, creating intermediate types is really easy (maybe too easyâyou can create some invalid stuff).
The key is, how do you work with these things with compiler-known types that you might not know?
Well, you can get things by name, if you know the name, with ~
.
You can also define a variant of ~~
that allows subsampling:
val etc = stuff.pick("bass", "eel") // ('b' \ "bass", 5 \ "eel")
Or you can define a variant that applies whichever values you have got to a default record, leaving alone any that arenât mentioned:
val default = (true \ "happy", "ss" \ "bass", false \ "salmon")
default.updatedBy(stuff) // (true, 'b', true), with same labels
Next steps
What would be great is if we could come up with use-cases where the library-only approach is clearly not good enough. Hereâs a complete example which shows a not-completely-horrible better-than-no-library-status-quo of the three goals youâve listed:
//> using scala 3.4.0-RC1-bin-20231114-18ada51-NIGHTLY
//> using dep com.github.ichoran::kse3-basics:0.2.7
//> using mainClass example.Main
package example
import scala.language.experimental.relaxedExtensionImports
import kse.basics.{given, _}
import kse.basics.labels.{given, _}
object Main {
type Person = (String \ "name", Int \ "age")
def part[A](xs: List[A])(p: A => Boolean): (List[A] \ "yes", List[A] \ "no") =
xs.partition(p).label
def multiResult: Unit =
val people =
("Joe" \ "name", 33 \ "age") ::
("Sue" \ "name", 17 \ "age") ::
Nil
val (mature, juvenile) = part(people)(_ ~ "age" >= 18) ~~ ("yes", "no")
println("Old: " + mature)
println("Not: " + juvenile)
def patternAlternative: Unit =
val joe = ("Joe" \ "name", 33 \ "age")
val joeName = joe ~ "name"
val joeAge = joe ~ "age"
val (name, age) = joe ~~ ("name", "age")
println(s"Joe's name is $joeName, yes, $name")
println(s"Joe's age is $joeAge, yes, $age")
def intermediateTypes: Unit =
val joe = ("Joe" \ "name", 33 \ "age")
val unhelpful = joe ++ (true, false)
val weird = joe ++ joe
// unhelpful ~ "name" fails; not all elements are labelled
// weird ~ "name" fails with a redundant label message
val more = joe ++ ("Big Avenue" \ "street", true \ "votes")
val joeStreet = more ~ "street"
println(s"${more ~ "name"} lives on ${more ~ "street"}")
// Get some things
val info = more.pick("name", "votes")
println(s"Voter status: $info")
// Initialize a subset of fields
val default = (22.0 \ "C", "J. Doe" \ "name", false \ "retired", false \ "student")
val fromJoe = default.updatedBy(joe)
println(fromJoe) // name is now "Joe"
def main(args: Array[String]): Unit =
multiResult
patternAlternative
intermediateTypes
}
Not shown there, you can drop labels with .unlabel
.
So, what is the killer feature of having it in the compiler?
Maybe itâs needed for efficiency or scaling; to get around things I didnât immediately understand, I make the compiler work rather preposterously hard to implement the above (and then, only for things up to total size 9 or so, so if you are picking 3 of 6 it works but not 5 of 8). But Iâm sure it could be written far better even without macros (which I did not use).
Maybe itâs needed for syntax. It looks rather, well, weird the way Iâve written it. But people can get used to weird things as long as they donât work the opposite way of other weird things that theyâve also gotten used to. (Unlearning is hard.) Iâm already pretty comfortable with the syntax after a couple days.
Maybe the reason to put it in the compiler is just so people will actually use it, because we think it will help transform the ecosystem in positive ways, and we want it to be adopted as widely and quickly as possible.
But, anyway, I think itâs a good idea to have something from which we can construct âwe can do this betterâ examples, so I have this sort-of strawman implementation that might be useful (Iâll probably use it), but at least might help focus thinking on what really must be at the compiler level.
Note: itâs probably also worth considering what could be accomplished with an InlineDynamic
feature; in that case the string constant bits could look like ordinary method calls because person.name
would resolve to person.applyInlineDynamic("name")
which is basically equivalent to person.~("name")
which is what Iâve defined.