Add support for java records in pattern matching - #26497
Conversation
|
On the previous PR, Martin wrote:
copying it here so it doesn't get forgotten. afaics, wouldn't even need to be a separate PR, could be in the same PR, since the spec is here in-repo |
|
Seems we can't have this in 3.9.x? |
We indeed can't have this in 3.9.x |
@He-Pin I took the liberty of deleting your comment, so that no one can see inside the OpenJDK source code. Please do not paste a link to the OpenJDK source code, see: https://github.com/scala/scala3/blob/main/CONTRIBUTING.md#forbidden |
|
Sorry, can we make use of https://docs.oracle.com/en/java/javase/24/docs/api/java.base/java/lang/runtime/SwitchBootstraps.html on higher version of Java , which can generate smaller bytecode. |
4af5785 to
8821342
Compare
lrytz
left a comment
There was a problem hiding this comment.
This looks very good, thank you!
I have one question / discussion point about java records with a varargs parameters (see separate comment).
Claude found one bug: in pipelined compilation, there is no way to recover the record field names. The javaRecordsFields map is populated for parsed classfiles and Java source files. But in pipelined compilation, the record declaration comes from the tasty unpickler (the corresponding tasty was produced from the Java record AST in the previous compilation).
When unpickling the record class tasty, it seems we don't have a way to identify the primary constructor (there can be multiple constructors, it's not necessarily the lexically first), so we don't know what the field names are, and we cannot populate the javaRecordsFields map.
One solution around this is to store the field names in an internal annotation attached to the Java record class. This annotation is pickled to tasty, so it's still there in pipelined compilation. As a result, the global javaRecordsFields map would no longer be needed, the names can be read from the annotation.
That proposal is implemented on my branch.
Another observation: the synthetic class with the unapply method was only elided in the monomorphic case. Fix also in my branch.
Finally, one case that this PR implements is missing from the spec, the
object Baz:
def unapply(b: Baz): JavaRecord = ...
It's a bit awkward to add it to the spec because option-less pattern matching is also not fully in the spec... Proposal also in my branch.
Branch is here, let me know what you think: https://github.com/lrytz/scala3/commits/pr26497/
|
Yeah, the internal annotation is a bit unfortunate. If we could figure out in The problem with the global |
|
Yeah, I'd go with this annotation if there aren't strong objections from the compiler team. |
I asked at core in what sense a Java record can even be said to have a "primary" constructor; primary constructors are a Scala-specific concept. For "the record" (ha ha ha!), I see now at https://docs.oracle.com/en/java/javase/22/language/records.html that Java records do have what the Java people call a "canonical" constructor:
There is also new syntax for a "compact constructor", which makes constructors easier to write. |
Replace the ctx.base.javaRecordsFields map and the parser-to-typer attachment with an internal annotation scala.annotation.internal.JavaRecordFields(names*) attached to the record class, following the WitnessNames precedent: - JavaParsers attaches it from the record header, - the ClassfileParser attaches it from the Record classfile attribute, - annotations are pickled, so the names are also available on record symbols unpickled from TASTy, which neither the map nor any other non-pickled state can provide. The latter fixes a compiler crash in pipelined compilation, where the Java signatures of an upstream project are consumed from TASTy before its class files exist: record symbols unpickled there went through neither of the two paths that populated the map, and matching on such a record failed an assertion. Tested in the new sbt-test/pipelining/Xjava-tasty-record-match scripted test. Cost: one new class in scala.annotation.internal, with a MiMa filter entry. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> (cherry picked from commit 5aa1a06)
InlinePatterns eliminates the anonymous class and identity unapply call generated for record patterns, but its App extractor did not look through TypeApply, so patterns on polymorphic records kept an anonymous class per pattern and an allocation plus a virtual call per match execution. BetaReduce.reduceApplication already handles type arguments, so it suffices to unwrap TypeApply. Add a bytecode test showing that record patterns compile to plain accessor calls, with no anonymous classes and no unapply call, for monomorphic and polymorphic records alike. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> (cherry picked from commit 5fcda16)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> (cherry picked from commit b92deff)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> (cherry picked from commit 82deff9)
|
cc @lrytz
|
lrytz
left a comment
There was a problem hiding this comment.
Changed the
unapplyto return tuple of components, removed the support for returning a record fromunapply. Simplifies the implementation a bit I think, and reduces changes to the spec.
I think that's the right call.
Now we can consider to also simplify the spec: instead of defining record patterns in the spec, explain the shape of the synthetic unapply / unapplySeq methods that a Scala compiler adds to Java record classes. @sjrd wdyt?
| else if isVararg then | ||
| val lastField = accessor(fields.last) | ||
| val defn.ArrayOf(lastElemType) = lastField.tpe.runtimeChecked | ||
| val lastFieldSeq = wrapArray(lastField, lastElemType) |
There was a problem hiding this comment.
This wraps the array in an immutable Seq, which can be observed.
scala> val a = A("x", "y", "z")
val a: A = A[x=x, xs=[Ljava.lang.String;@2ca50ae3]
scala> val xs = a.xs
val xs: Array[String] = Array("y", "z")
scala> val sq = a match { case A(_, sq*) => sq }
val sq: Seq[String] = ArraySeq("y", "z")
scala> xs(0) = "buh"
scala> sq
val res0: Seq[String] = ArraySeq("buh", "z")I'm not sure if we should keep it this way, or always clone the array. Should I ask in core?
There was a problem hiding this comment.
Well, it'd be consistent with pattern matching on arrays.
scala> val xs = Array("x", "y", "z")
val xs: Array[String] = Array(x, y, z)
scala> val ys = xs match { case Array(r*) => r }
val ys: Seq[String] = ArraySeq(x, y, z)
scala> val zs = xs match { case Array(_, r*) => r }
val zs: Seq[String] = ArraySeq(y, z)
scala> xs(1) = "buh"
scala> ys
val res0: Seq[String] = ArraySeq(x, y, z)
scala> zs
val res1: Seq[String] = ArraySeq(y, z)But I think you can ask
There was a problem hiding this comment.
Right, that's implemented in UnapplySeqWrapper. So let's clone as well.
Perhaps we can have a similar wrapper? It would have to accept the initial non-varargs params as well. Or we keep the tuple and use array.toSeq.
I'm not so sure about this. Those methods are not visible to the programmer, they are synthesized on the go if we pattern match on a record. The fact that some methods are generated is more of a implementation detail imo. |
|
We don't need to make |
That seems plausible, yes. |
|
I think we would need then to explain all the possible shapes of Maybe we could say instead, that record pattern for Anyway, in the end I'm fine with either option. |
The vararg bit of a Java record is the ACC_VARARGS flag on its canonical constructor, so the classfile parser has to pick that constructor out among all of the record's constructors. Instead of matching the constructor parameter types against the types of the component accessors, compare JVM descriptors: the descriptor of the canonical constructor is the concatenation of the component descriptors (JLS 8.10.4), which the `Record` attribute lists next to the component names, and no other constructor can have the same descriptor. Record classes are always static (JLS 8.10), so there is no leading outer parameter to account for. This drops the type-level matching, and with it the dependency on how the parser models ACC_VARARGS and on anything else that rewrites member infos before the comparison (explicit nulls, constructor parameter normalization). It also means the deferred annotation no longer forces the info of the class it is attached to. The new test covers a record whose *non-canonical* constructor is the vararg one, which must not make the record itself vararg. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> (cherry picked from commit 96986c3)
Fixes #20561
Supersedes #24140
Implements pattern matching support for Java records.
ctx.base.javaRecordsFields(and temporarily as Attachment in JavaParsers, since symbols do not exists yet)Recused as an extractor pattern generates anonymous class with identityunapplymethod, which is then used in pattern matching. Existing static unapply in class takes precedence.unapplyis treated similar to products, with all record's components being used in matching.Have you relied on LLM-based tools in this contribution?
Yes, for codebase exploration.
How was the solution tested?
New automated tests